From ca22bda4b8f0f17b13c284acd3b983bf24a7c8f8 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 22 Jun 2026 14:23:05 +0300 Subject: [PATCH 01/29] feat: make core, dao and search Kotlin Multiplatform (add iOS targets) Adds iOS (iosArm64 + iosSimulatorArm64) alongside the existing JVM/Android targets and moves platform-agnostic code into commonMain. core - add iOS targets (serialization-only, no code changes) dao - add iOS targets; shared androidJvmMain source set (Android is JVM-based) - Dispatchers.IO used directly from commonMain (kotlinx.coroutines.IO) - ConcurrentHashMap -> newConcurrentMap expect/actual; iOS impl is an NSLock-backed thread-safe map - CatalogLoader now reads via FileKit (suspend) instead of java.io.File, so it works on iOS; PlatformFiles expect/actual removed - getEnvironmentVariable: shared System.getenv on JVM/Android, stub on iOS search - split into common interfaces + data (SearchEngine, SearchSession, SnippetProvider, LineHit, SearchPage, SearchFacets, HebrewTextUtils) and the JVM-only Lucene engine (LuceneSearchEngine, MagicDictionaryIndex) - StubSearchEngine (no-op) in commonMain for Android/iOS via DI - Closeable -> kotlin.AutoCloseable build - align AGP 9.1.1 and Kotlin 2.3.21; FileKit 0.14.1 - unique Android namespaces for core (.core) and dao (.dao) --- SeforimMagicIndexer | 2 +- core/build.gradle.kts | 6 +- dao/build.gradle.kts | 17 +- .../repository/PlatformSupport.jvmAndroid.kt | 4 + .../seforimlibrary/env/EnvJvmAndroid.kt} | 1 - .../seforimlibrary/dao/CatalogLoader.kt | 59 +-- .../dao/repository/PlatformSupport.kt | 11 + .../dao/repository/SeforimRepository.kt | 339 +++++++++--------- .../dao/repository/PlatformSupport.ios.kt | 31 ++ .../kdroidfilter/seforimlibrary/env/EnvIos.kt | 4 + .../kdroidfilter/seforimlibrary/env/EnvJvm.kt | 4 - gradle/libs.versions.toml | 6 +- search/build.gradle.kts | 14 +- .../seforimlibrary/search/HebrewTextUtils.kt | 0 .../seforimlibrary/search/SearchEngine.kt | 3 +- .../seforimlibrary/search/SearchSession.kt | 3 +- .../seforimlibrary/search/SnippetProvider.kt | 0 .../seforimlibrary/search/StubSearchEngine.kt | 38 ++ 18 files changed, 307 insertions(+), 235 deletions(-) create mode 100644 dao/src/androidJvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.jvmAndroid.kt rename dao/src/{androidMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvAndroid.kt => androidJvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvJvmAndroid.kt} (99%) create mode 100644 dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.kt create mode 100644 dao/src/iosMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.ios.kt create mode 100644 dao/src/iosMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvIos.kt delete mode 100644 dao/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/env/EnvJvm.kt rename search/src/{jvmMain => commonMain}/kotlin/io/github/kdroidfilter/seforimlibrary/search/HebrewTextUtils.kt (100%) rename search/src/{jvmMain => commonMain}/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchEngine.kt (98%) rename search/src/{jvmMain => commonMain}/kotlin/io/github/kdroidfilter/seforimlibrary/search/SearchSession.kt (97%) rename search/src/{jvmMain => commonMain}/kotlin/io/github/kdroidfilter/seforimlibrary/search/SnippetProvider.kt (100%) create mode 100644 search/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/search/StubSearchEngine.kt 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/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/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 newConcurrentMap(): MutableMap = 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/repository/PlatformSupport.kt b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.kt new file mode 100644 index 00000000..d4d8f47c --- /dev/null +++ b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/PlatformSupport.kt @@ -0,0 +1,11 @@ +package io.github.kdroidfilter.seforimlibrary.dao.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +/** Dispatcher for blocking DB I/O. Testing whether Dispatchers.IO resolves directly in commonMain. */ +internal val ioDispatcher: CoroutineDispatcher = Dispatchers.IO + +/** Thread-safe map on JVM/Android (ConcurrentHashMap); plain map stub on native. */ +internal expect fun newConcurrentMap(): MutableMap 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..63316396 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() + // coming from `getCategory`. newConcurrentMap is a ConcurrentHashMap on JVM/Android; + // the iOS actual is a plain map (stub) until a concurrent iOS impl is wired in. + private val categoryCache = newConcurrentMap() // 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() + private val bookOrderIndexCache = newConcurrentMap() 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>) = withContext(Dispatchers.IO) { + suspend fun insertLineTocBatch(mappings: List>) = 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>) = withContext(Dispatchers.IO) { + suspend fun bulkUpsertLineToc(pairs: List>) = 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 = withContext(Dispatchers.IO) { + suspend fun getTocEntriesForBook(bookId: Long): List = 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 = withContext(Dispatchers.IO) { + override suspend fun getLineIdsForTocEntry(tocEntryId: Long): List = withContext(ioDispatcher) { database.lineTocQueriesQueries.selectLineIdsByTocEntryId(tocEntryId).executeAsList() } /** * Returns mappings (lineId -> tocEntryId) for a book ordered by line index. */ - suspend fun getLineTocMappingsForBook(bookId: Long): List = withContext(Dispatchers.IO) { + suspend fun getLineTocMappingsForBook(bookId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun getRootCategories(): List = 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 = withContext(Dispatchers.IO) { + suspend fun getCategoryChildren(parentId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun getDescendantCategoryIds(ancestorId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun getAncestorCategoryIds(categoryId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun findCategoriesByTitleLike(pattern: String, limit: Int = 20): List = 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 = withContext(Dispatchers.IO) { + suspend fun getBooksByCategory(categoryId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun getBooksUnderCategoryTree(ancestorCategoryId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun getAllBookAltFlags(): Map = 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 = withContext(Dispatchers.IO) { + suspend fun findBooksByTitleLike(pattern: String, limit: Int = 20): List = 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 = withContext(Dispatchers.IO) { + suspend fun findBooksByTitleLikeCore(pattern: String, limit: Int = 20): List = 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 = withContext(Dispatchers.IO) { + suspend fun searchBooksByAuthor(authorName: String): List = 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 = withContext(Dispatchers.IO) { + private suspend fun getBookAuthors(bookId: Long): List = 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 = withContext(Dispatchers.IO) { + private suspend fun getBookTopics(bookId: Long): List = 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 = withContext(Dispatchers.IO) { + private suspend fun getBookPubPlaces(bookId: Long): List = 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 = withContext(Dispatchers.IO) { + private suspend fun getBookPubDates(bookId: Long): List = 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 @@ -1025,21 +1024,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 +1054,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 = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { database.lineQueriesQueries.selectByBookIdRange( bookId = bookId, lineIndex = startIndex.toLong(), @@ -1090,7 +1089,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } suspend fun getLinesByIds(ids: Collection): List = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { if (ids.isEmpty()) return@withContext emptyList() database.lineQueriesQueries.selectByIds(ids).executeAsList().map { it.toModel() } } @@ -1102,7 +1101,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 +1115,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 +1130,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 +1140,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) = withContext(Dispatchers.IO) { + suspend fun insertLinesBatch(lines: List) = withContext(ioDispatcher) { if (lines.isEmpty()) return@withContext database.transaction { @@ -1160,7 +1159,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 +1204,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 +1213,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>) = withContext(Dispatchers.IO) { + suspend fun bulkUpdateLineTocEntryIds(pairs: List>) = withContext(ioDispatcher) { if (pairs.isEmpty()) return@withContext database.transaction { for ((lineId, tocEntryId) in pairs) { @@ -1229,7 +1228,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Bulk variant of [updateTocEntryLineId]. Each pair is (tocEntryId, lineId). */ - suspend fun bulkUpdateTocEntryLineIds(pairs: List>) = withContext(Dispatchers.IO) { + suspend fun bulkUpdateTocEntryLineIds(pairs: List>) = withContext(ioDispatcher) { if (pairs.isEmpty()) return@withContext database.transaction { for ((tocEntryId, lineId) in pairs) { @@ -1240,42 +1239,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 = withContext(Dispatchers.IO) { + suspend fun getBookToc(bookId: Long): List = withContext(ioDispatcher) { database.tocQueriesQueries.selectByBookId(bookId).executeAsList().map { it.toModel() } } - suspend fun getBookRootToc(bookId: Long): List = withContext(Dispatchers.IO) { + suspend fun getBookRootToc(bookId: Long): List = withContext(ioDispatcher) { database.tocQueriesQueries.selectRootByBookId(bookId).executeAsList().map { it.toModel() } } - suspend fun getTocChildren(parentId: Long): List = withContext(Dispatchers.IO) { + suspend fun getTocChildren(parentId: Long): List = withContext(ioDispatcher) { database.tocQueriesQueries.selectChildren(parentId).executeAsList().map { it.toModel() } } - override suspend fun getAncestorPath(tocId: Long): List = withContext(Dispatchers.IO) { + override suspend fun getAncestorPath(tocId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun getAltTocStructuresForBook(bookId: Long): List = 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 +1308,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 +1344,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 = withContext(Dispatchers.IO) { + suspend fun getAltRootToc(structureId: Long): List = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.selectAltRootByStructureId(structureId).executeAsList().map { it.toModel() } } - suspend fun getAltTocChildren(parentId: Long): List = withContext(Dispatchers.IO) { + suspend fun getAltTocChildren(parentId: Long): List = withContext(ioDispatcher) { database.altTocEntryQueriesQueries.selectAltChildren(parentId).executeAsList().map { it.toModel() } } - suspend fun getAltTocEntriesForStructure(structureId: Long): List = withContext(Dispatchers.IO) { + suspend fun getAltTocEntriesForStructure(structureId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun getLineAltTocMappings(structureId: Long): List = withContext(ioDispatcher) { database.lineAltTocQueriesQueries.selectByStructure(structureId).executeAsList().map { LineAltTocMapping( lineId = it.lineId, @@ -1393,20 +1392,20 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } } - suspend fun getLineIdsForAltTocEntry(altTocEntryId: Long): List = withContext(Dispatchers.IO) { + suspend fun getLineIdsForAltTocEntry(altTocEntryId: Long): List = withContext(ioDispatcher) { database.lineAltTocQueriesQueries.selectLineIdsByAltTocEntry(altTocEntryId).executeAsList() } // --- TocText methods --- // Returns all distinct tocText values using generated SQLDelight query - suspend fun getAllTocTexts(): List = withContext(Dispatchers.IO) { + suspend fun getAllTocTexts(): List = 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 +1461,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 +1517,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 +1543,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L suspend fun bulkUpdateTocEntryFlags( hasChildrenIds: Collection, lastChildIds: Collection, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { if (hasChildrenIds.isEmpty() && lastChildIds.isEmpty()) return@withContext database.transaction { for (id in hasChildrenIds) { @@ -1564,7 +1563,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 +1600,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of all connection types */ - suspend fun getAllConnectionTypes(): List = withContext(Dispatchers.IO) { + suspend fun getAllConnectionTypes(): List = withContext(ioDispatcher) { database.connectionTypeQueriesQueries.selectAll().executeAsList().map { ConnectionType.fromString(it.name) } @@ -1609,11 +1608,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 +1623,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L lineIds: List, activeCommentatorIds: Set = emptySet(), includeSources: Boolean = false, - ): List = withContext(Dispatchers.IO) { + ): List = withContext(ioDispatcher) { if (lineIds.isEmpty()) return@withContext emptyList() val forward = database.linkQueriesQueries.selectLinksBySourceLineIds(lineIds).executeAsList() .filter { activeCommentatorIds.isEmpty() || it.targetBookId in activeCommentatorIds } @@ -1673,7 +1672,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L lineIds: List, activeCommentatorIds: Set = emptySet(), includeSources: Boolean = false, - ): List = withContext(Dispatchers.IO) { + ): List = withContext(ioDispatcher) { if (lineIds.isEmpty()) return@withContext emptyList() val forward = database.linkQueriesQueries.selectLinkSummariesBySourceLineIds(lineIds).executeAsList() .filter { activeCommentatorIds.isEmpty() || it.targetBookId in activeCommentatorIds } @@ -1712,7 +1711,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L } suspend fun getAvailableCommentators(bookId: Long): List = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { database.linkQueriesQueries.selectCommentatorsByBook(bookId).executeAsList() .map { CommentatorInfo( @@ -1732,7 +1731,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L offset: Int, limit: Int, distinctByTargetLine: Boolean = false - ): List = withContext(Dispatchers.IO) { + ): List = 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 +1961,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L lineIds: List, activeCommentatorIds: Set = emptySet(), connectionTypes: Set = setOf(ConnectionType.COMMENTARY), - ): List = withContext(Dispatchers.IO) { + ): List = withContext(ioDispatcher) { if (lineIds.isEmpty() || connectionTypes.isEmpty()) return@withContext emptyList() if (ConnectionType.SOURCE in connectionTypes) { require(connectionTypes.size == 1) { @@ -2027,7 +2026,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L bookId: Long, offset: Int, limit: Int - ): List = withContext(Dispatchers.IO) { + ): List = withContext(ioDispatcher) { database.linkQueriesQueries.selectCommentatorsByBook(bookId) .executeAsList() .drop(offset) @@ -2042,7 +2041,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 +2114,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) = withContext(Dispatchers.IO) { + suspend fun insertLinksBatch(links: List) = withContext(ioDispatcher) { if (links.isEmpty()) return@withContext // Pre-resolve connection type IDs per type name to avoid repeated lookups. @@ -2169,7 +2168,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 +2213,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 +2228,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 +2245,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 +2261,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 +2273,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 +2288,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 +2303,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 +2321,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 +2335,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 +2346,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 +2357,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 +2368,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 +2379,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 +2392,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of books that have any links */ - suspend fun getBooksWithAnyLinks(): List = withContext(Dispatchers.IO) { + suspend fun getBooksWithAnyLinks(): List = 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 +2412,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of books that have source links */ - suspend fun getBooksWithSourceLinks(): List = withContext(Dispatchers.IO) { + suspend fun getBooksWithSourceLinks(): List = 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 +2432,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of books that have target links */ - suspend fun getBooksWithTargetLinks(): List = withContext(Dispatchers.IO) { + suspend fun getBooksWithTargetLinks(): List = 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 +2452,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 +2464,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 +2476,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 +2489,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * * @return A list of all books */ - suspend fun getAllBooks(): List = withContext(Dispatchers.IO) { + suspend fun getAllBooks(): List = withContext(ioDispatcher) { logger.d { "Getting all books" } val books = database.bookQueriesQueries.selectAll().executeAsList() logger.d { "Found ${books.size} books" } @@ -2508,7 +2507,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Returns the IDs of all base books (isBaseBook = 1). */ - suspend fun getBaseBookIds(): List = withContext(Dispatchers.IO) { + suspend fun getBaseBookIds(): List = withContext(ioDispatcher) { database.bookQueriesQueries.selectBaseIds().executeAsList() } @@ -2518,7 +2517,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 +2530,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 +2542,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 = withContext(Dispatchers.IO) { + suspend fun getDefaultCommentatorIdsForBook(bookId: Long): List = 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) = withContext(Dispatchers.IO) { + suspend fun setDefaultCommentatorsForBook(bookId: Long, commentatorBookIds: List) = withContext(ioDispatcher) { database.defaultCommentatorQueriesQueries.deleteByBookId(bookId) commentatorBookIds.forEachIndexed { index, commentatorBookId -> database.defaultCommentatorQueriesQueries.insert( @@ -2565,7 +2564,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 +2573,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 = withContext(Dispatchers.IO) { + suspend fun getDefaultTargumIdsForBook(bookId: Long): List = 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) = withContext(Dispatchers.IO) { + suspend fun setDefaultTargumForBook(bookId: Long, targumBookIds: List) = withContext(ioDispatcher) { database.defaultTargumQueriesQueries.deleteByBookId(bookId) targumBookIds.forEachIndexed { index, targumBookId -> database.defaultTargumQueriesQueries.insert( @@ -2596,7 +2595,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 +2604,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) = withContext(Dispatchers.IO) { + suspend fun bulkInsertBookAcronyms(bookId: Long, terms: Collection) = withContext(ioDispatcher) { if (terms.isEmpty()) return@withContext for (t in terms) database.acronymQueriesQueries.insert(bookId, t) } @@ -2620,21 +2619,21 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L /** * Returns all acronym terms for a given book. */ - suspend fun getAcronymsForBook(bookId: Long): List = withContext(Dispatchers.IO) { + suspend fun getAcronymsForBook(bookId: Long): List = 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 = withContext(Dispatchers.IO) { + suspend fun findBookIdsByAcronym(term: String): List = 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 = withContext(Dispatchers.IO) { + suspend fun findBooksByAcronymLike(pattern: String, limit: Int = 20): List = withContext(ioDispatcher) { val ids = database.acronymQueriesQueries.selectBookIdsByTermLike(pattern, limit.toLong()).executeAsList() ids.distinct().mapNotNull { id -> getBook(id) } } @@ -2642,7 +2641,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 = withContext(Dispatchers.IO) { + suspend fun findBooksByAcronymExact(term: String, limit: Int = 20): List = withContext(ioDispatcher) { val ids = database.acronymQueriesQueries.selectBookIdsByTerm(term).executeAsList() ids.take(limit).mapNotNull { id -> getBook(id) } } @@ -2651,7 +2650,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 +2663,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 +2671,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L * Returns all line IDs that are heading lines (content starts with = withContext(Dispatchers.IO) { + suspend fun getHeadingLineIds(): Set = withContext(ioDispatcher) { val result = mutableSetOf() driver.executeQuery( identifier = null, @@ -2700,27 +2699,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 +2729,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 +2739,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 +2752,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 +2768,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/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..fba73c25 --- /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 the read/write paths the repository +// actually uses (get / getOrPut) guarded by an NSLock. +private class NsLockMutableMap( + private val backing: MutableMap = LinkedHashMap(), +) : MutableMap 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 newConcurrentMap(): MutableMap = 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/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/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?, + lineIds: Collection?, + baseBookOnly: Boolean, + ): SearchSession? = null + + override fun searchBooksByTitlePrefix(query: String, limit: Int): List = emptyList() + + override fun buildSnippet(rawText: String, query: String, near: Int): String = rawText + + override fun buildHighlightTerms(query: String): List = emptyList() + + override fun computeFacets( + query: String, + near: Int, + bookFilter: Long?, + categoryFilter: Long?, + bookIds: Collection?, + lineIds: Collection?, + baseBookOnly: Boolean, + ): SearchFacets? = null + + override fun close() {} +} From 45b0106736a92025435c8ee7e71552a6de749172 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:58:42 +0000 Subject: [PATCH 02/29] fix(dao): align platform support comments with implementation --- .../seforimlibrary/dao/repository/PlatformSupport.kt | 5 ++--- .../seforimlibrary/dao/repository/SeforimRepository.kt | 2 +- .../seforimlibrary/dao/repository/PlatformSupport.ios.kt | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) 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 index d4d8f47c..5eeba76b 100644 --- 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 @@ -2,10 +2,9 @@ package io.github.kdroidfilter.seforimlibrary.dao.repository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.IO -/** Dispatcher for blocking DB I/O. Testing whether Dispatchers.IO resolves directly in commonMain. */ +/** Dispatcher for blocking DB I/O. */ internal val ioDispatcher: CoroutineDispatcher = Dispatchers.IO -/** Thread-safe map on JVM/Android (ConcurrentHashMap); plain map stub on native. */ +/** Thread-safe map (ConcurrentHashMap on JVM/Android, NSLock-backed map on iOS). */ internal expect fun newConcurrentMap(): MutableMap 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 63316396..82ab34e1 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 @@ -44,7 +44,7 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L // 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`. newConcurrentMap is a ConcurrentHashMap on JVM/Android; - // the iOS actual is a plain map (stub) until a concurrent iOS impl is wired in. + // the iOS actual is NSLock-backed. private val categoryCache = newConcurrentMap() // book.orderIndex is denormalized onto each link row at insert time (targetBookOrderIndex) 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 index fba73c25..c76dede7 100644 --- 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 @@ -2,8 +2,8 @@ package io.github.kdroidfilter.seforimlibrary.dao.repository import platform.Foundation.NSLock -// Thread-safe map for native: backing map by delegation, with the read/write paths the repository -// actually uses (get / getOrPut) guarded by an NSLock. +// Thread-safe map for native: backing map by delegation, with direct get/put operations +// guarded by an NSLock. private class NsLockMutableMap( private val backing: MutableMap = LinkedHashMap(), ) : MutableMap by backing { From f75ac4c3a7b079efed8a4f4ee520e43073b6747a Mon Sep 17 00:00:00 2001 From: BatshevaRich Date: Tue, 19 May 2026 10:02:44 +0300 Subject: [PATCH 03/29] feat(sefariasqlite): drive rename from otzaria-library/ForDB CSVs (#3) * feat(sefariasqlite): drive rename from otzaria-library/ForDB CSVs * gemini comments * more gemini comments --- generator/sefariasqlite/build.gradle.kts | 2 +- .../RenameCategoriesPostProcess.kt | 264 +++++++++++++++--- 2 files changed, 223 insertions(+), 43 deletions(-) diff --git a/generator/sefariasqlite/build.gradle.kts b/generator/sefariasqlite/build.gradle.kts index 354705eb..77257e66 100644 --- a/generator/sefariasqlite/build.gradle.kts +++ b/generator/sefariasqlite/build.gradle.kts @@ -80,7 +80,7 @@ tasks.register("generateSefariaSqlite") { // ./gradlew :sefariasqlite:renameCategories -PseforimDb=/path/to/seforim.db tasks.register("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..e9be1cd4 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,6 +2,8 @@ package io.github.kdroidfilter.seforimlibrary.sefariasqlite import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity +import java.net.URI +import java.nio.charset.StandardCharsets import java.nio.file.Paths import java.sql.Connection import java.sql.DriverManager @@ -9,20 +11,45 @@ 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): + * - ืชื™ืงื™ื•ืช.csv `old,new` โ€” category renames (exact match, prefix fallback) + * - ืกืคืจื™ื.csv `old,new` โ€” book title renames (exact match) + * - Moving files.csv `name,sourcePath,destPath` (simple CSV; embedded newlines in quoted fields are not supported) + * + * The release zip does not include ForDB/, so the CSVs are fetched directly + * from raw.githubusercontent.com at task start. Download failures are + * non-fatal: the corresponding section becomes a no-op (logged as a warning) + * and the rest of the run proceeds. This is intentional โ€” a GitHub outage + * should not break the CI build; an un-post-processed DB is preferable to a + * red pipeline. The warning lines are the signal to investigate. + * + * 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 destination category path to already exist; missing + * destinations are skipped with a warning (no auto-creation). + * + * Order of operations: category renames โ†’ book renames โ†’ book moves. Paths in + * Moving files.csv must therefore reference the POST-rename category names. + * If a move references a pre-rename destPath, resolveCategoryPath returns null + * and the move is silently skipped with a "destination not found" warning. + * * Usage: * ./gradlew -p SeforimLibrary :sefariasqlite:renameCategories -PseforimDb=/path/to/seforim.db * * Env alternatives: * SEFORIM_DB */ +private const val FOR_DB_BASE = "https://raw.githubusercontent.com/Otzaria/otzaria-library/main/ForDB" +private const val CATEGORY_RENAMES_URL = "$FOR_DB_BASE/%D7%AA%D7%99%D7%A7%D7%99%D7%95%D7%AA.csv" +private const val BOOK_RENAMES_URL = "$FOR_DB_BASE/%D7%A1%D7%A4%D7%A8%D7%99%D7%9D.csv" +private const val BOOK_MOVES_URL = "$FOR_DB_BASE/Moving%20files.csv" + fun main(args: Array) { Logger.setMinSeverity(Severity.Info) val logger = Logger.withTag("RenameCategories") @@ -40,24 +67,11 @@ fun main(args: Array) { 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. + // ืชื™ืงื™ื•ืช.csv and ืกืคืจื™ื.csv have no header; Moving files.csv has one. + val categoryRenames: List> = parsePairs(downloadCsv(CATEGORY_RENAMES_URL, logger)) + val bookRenames: List> = parsePairs(downloadCsv(BOOK_RENAMES_URL, logger)) + val bookMoves: List = parseBookMoves(downloadCsv(BOOK_MOVES_URL, logger), logger) try { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> @@ -65,25 +79,86 @@ fun main(args: Array) { var totalRenamed = 0 var totalMerged = 0 - + var categoryFailures = 0 for ((oldName, newName) in categoryRenames) { - val result = renameOrMergeCategory(conn, oldName, newName, logger) - when (result) { - is RenameResult.Renamed -> totalRenamed += result.count - is RenameResult.Merged -> totalMerged += result.booksMoved - is RenameResult.NotFound -> { /* skip */ } + try { + when (val result = renameOrMergeCategory(conn, oldName, newName, logger)) { + is RenameResult.Renamed -> totalRenamed += result.count + is RenameResult.Merged -> totalMerged += result.booksMoved + is RenameResult.NotFound -> logger.w { "Category rename: '$oldName' not found; skipping" } + } + } catch (e: Exception) { + categoryFailures++ + logger.w(e) { "Category rename '$oldName' -> '$newName' failed; skipping" } + } + } + logger.i { "Category processing complete. Renamed: $totalRenamed, Merged: $totalMerged books, Failures: $categoryFailures" } + + var totalBookRenamed = 0 + var bookRenameFailures = 0 + for ((oldTitle, newTitle) in bookRenames) { + try { + totalBookRenamed += renameBookTitle(conn, oldTitle, newTitle, logger) + } catch (e: Exception) { + bookRenameFailures++ + logger.w(e) { "Book rename '$oldTitle' -> '$newTitle' failed; skipping" } } } + logger.i { "Book renames complete. Renamed: $totalBookRenamed books, Failures: $bookRenameFailures" } + + var totalMoved = 0 + var moveFailures = 0 + for (move in bookMoves) { + try { + if (applyBookMove(conn, move, logger)) totalMoved++ + } catch (e: Exception) { + moveFailures++ + logger.w(e) { "Book move '${move.name}' -> '${move.destPath}' failed; skipping" } + } + } + logger.i { "Book moves complete. Moved: $totalMoved books, Failures: $moveFailures" } conn.commit() - logger.i { "Category processing complete. Renamed: $totalRenamed, Merged: $totalMerged books" } + logger.i { + "Post-process done: categories renamed=$totalRenamed merged=$totalMerged " + + "(failures=$categoryFailures); books renamed=$totalBookRenamed " + + "(failures=$bookRenameFailures); books moved=$totalMoved (failures=$moveFailures)" + } } } 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 โ€” skips blanks and malformed lines. */ +private fun parsePairs(lines: List): List> = lines.mapNotNull { line -> + val f = parseCsvLine(line).map { it.trim() } + if (f.size >= 2 && f[0].isNotEmpty() && f[1].isNotEmpty()) f[0] to f[1] else null +} + +/** + * `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. + * If the first line doesn't look like a header, treats it as data and logs a + * warning so a header-less upload doesn't silently lose row 0. + */ +private fun parseBookMoves(lines: List, logger: Logger): List { + val firstLower = lines.firstOrNull()?.lowercase() + val isHeader = firstLower != null && "source path" in firstLower && "destination path" in firstLower + val body = if (isHeader) { + lines.drop(1) + } else { + if (lines.isNotEmpty()) logger.w { "Moving files.csv: no header detected; treating first row as data" } + lines + } + return body.mapNotNull { line -> + val f = parseCsvLine(line).map { it.trim() } + if (f.size >= 3 && f[0].isNotEmpty() && f[2].isNotEmpty()) BookMove(f[0], f[1], f[2]) else null + } +} + private sealed class RenameResult { data class Renamed(val count: Int) : RenameResult() data class Merged(val booksMoved: Int) : RenameResult() @@ -101,18 +176,8 @@ private fun renameOrMergeCategory( newName: String, logger: Logger ): RenameResult { - // Find all source categories with oldName - val sourceCats = mutableListOf>() // (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) - } - } - } + // Find all source categories with oldName (exact match, falling back to prefix) + val sourceCats = findSourceCategories(conn, oldName) if (sourceCats.isEmpty()) { return RenameResult.NotFound @@ -190,3 +255,118 @@ private fun deleteCategory(conn: Connection, categoryId: Long) { stmt.executeUpdate() } } + +/** + * Exact match first; only if nothing matches exactly, fall back to prefix match. + * This preserves the safer "literal" interpretation for the common case while + * still allowing rules like "ืจืืฉื•ื ื™ื ืขืœ" to sweep "ืจืืฉื•ื ื™ื ืขืœ ื”ืชืœืžื•ื“", + * "ืจืืฉื•ื ื™ื ืขืœ ื”ืžืฉื ื”", etc. + */ +private fun findSourceCategories(conn: Connection, pattern: String): List> { + fun query(sql: String, param: String): List> { + val rows = mutableListOf>() + 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 + } + + val exact = query("SELECT id, parentId FROM category WHERE title = ?", pattern) + if (exact.isNotEmpty()) return exact + + val likePattern = pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%" + return query("SELECT id, parentId FROM category WHERE title LIKE ? ESCAPE '\\'", likePattern) +} + +private fun renameBookTitle(conn: Connection, oldTitle: String, newTitle: String, logger: Logger): Int { + val n = conn.prepareStatement("UPDATE book SET title = ? WHERE title = ?").use { stmt -> + stmt.setString(1, newTitle) + stmt.setString(2, oldTitle) + stmt.executeUpdate() + } + if (n > 0) logger.i { "Renamed book '$oldTitle' -> '$newTitle' ($n rows)" } + else logger.w { "Book rename: '$oldTitle' not found; skipping" } + return n +} + +private data class BookMove(val name: String, val sourcePath: String, val destPath: String) + +/** + * Resolves destPath against the existing category tree and updates the matching + * book's categoryId. Missing destinations are skipped (no auto-creation). + * Disambiguates by source path when multiple books share a title; an empty + * sourcePath is only safe when the title is globally unique. + */ +private fun applyBookMove(conn: Connection, move: BookMove, logger: Logger): Boolean { + val candidates = mutableListOf>() // (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)) + } + } + if (candidates.isEmpty()) { + logger.w { "Book move: '${move.name}' not found; skipping" } + return false + } + + val sourceCatId = resolveCategoryPath(conn, move.sourcePath) + val bookId = when { + candidates.size == 1 -> candidates.single().first + sourceCatId != null -> candidates.firstOrNull { it.second == sourceCatId }?.first + else -> null + } + if (bookId == null) { + logger.w { "Book move: '${move.name}' has ${candidates.size} candidates; source '${move.sourcePath}' did not disambiguate; skipping" } + return false + } + + val destCatId = resolveCategoryPath(conn, move.destPath) + if (destCatId == null) { + logger.w { "Book move: destination '${move.destPath}' not found; skipping" } + return false + } + + 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 true +} + +/** 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 +} + +/** + * Downloads a CSV and returns its lines (UTF-8, BOM stripped from line 0 if present). + * Returns an empty list on failure (logs a warning); the corresponding section + * then becomes a no-op and the rest of the run proceeds. + */ +private fun downloadCsv(url: String, logger: Logger): List = try { + val conn = URI(url).toURL().openConnection().apply { + connectTimeout = 10_000 + readTimeout = 30_000 + } + val lines = conn.getInputStream().use { it.reader(StandardCharsets.UTF_8).readLines() } + if (lines.isEmpty()) lines else listOf(lines.first().removePrefix("\uFEFF")) + lines.drop(1) +} catch (e: Exception) { + logger.w(e) { "Failed to download $url; skipping section" } + emptyList() +} + From eb2aaa4a111eac33aaeb2166013b37805f82a9cb Mon Sep 17 00:00:00 2001 From: BatshevaRich Date: Tue, 19 May 2026 15:36:42 +0300 Subject: [PATCH 04/29] Feat/talmud flatten gershayim (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sefariasqlite): flatten Talmud categories + normalize gershayim to " Flatten "ืชืœืžื•ื“/ื‘ื‘ืœื™" and "ืชืœืžื•ื“/ื™ืจื•ืฉืœืžื™" into single segments ("ืชืœืžื•ื“ ื‘ื‘ืœื™", "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™") across Sefaria import ordering and book payload categories. Switch sanitizeFolder + Generator label/title normalization to convert the Hebrew gershayim (ืด, U+05F4) to a plain double quote so category and book titles stay consistent across sources. * strip titles * removed duplicates --- .../seforimlibrary/otzariasqlite/Generator.kt | 18 ++++----- .../RenameCategoriesPostProcess.kt | 14 ++++++- .../sefariasqlite/SefariaBookPayloadReader.kt | 2 +- .../sefariasqlite/SefariaImportOrdering.kt | 38 +++++++++++++++++-- .../sefariasqlite/SefariaImportText.kt | 4 +- 5 files changed, 60 insertions(+), 16 deletions(-) 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..347b7513 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 @@ -123,12 +123,12 @@ class DatabaseGenerator( // Normalization helpers for categories/titles private fun normalizeHebrewLabel(raw: String): String { var s = raw.trim() - // Normalize common quote variants to Hebrew gershayim/geresh + // Normalize common quote variants to regular double-quote/geresh s = s.replace('\u201C', '"').replace('\u201D', '"') s = s.replace('\u2018', '\'').replace('\u2019', '\'') - s = s.replace("\"", "ืด") - s = s.replace("''", "ืด") - s = s.replace("ืณืณ", "ืด") + s = s.replace("ืด", "\"") + s = s.replace("''", "\"") + s = s.replace("ืณืณ", "\"") s = s.replace("`", "ืณ") s = s.replace("\u05f3", "ืณ") s = s.replace("\\s+".toRegex(), " ").trim() @@ -154,10 +154,10 @@ class DatabaseGenerator( private fun normalizeCategorySegments(rawTitle: String): List { val cleaned = normalizeHebrewLabel(rawTitle) return when (cleaned) { - "ืชืœืžื•ื“ ื‘ื‘ืœื™" -> listOf("ืชืœืžื•ื“", "ื‘ื‘ืœื™") - "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™", "ืชืœืžื•ื“ ื™ืจื•ืฉืœื™ื" -> listOf("ืชืœืžื•ื“", "ื™ืจื•ืฉืœืžื™") - "ืชื ืš", "ืชื \"ืš", "ืชื ืดืš" -> listOf("ืชื ืดืš") - "ืฉื•ืช", "ืฉื•\"ืช", "ืฉื•ืดืช" -> listOf("ืฉื•ืดืช") + "ืชืœืžื•ื“ ื‘ื‘ืœื™" -> listOf("ืชืœืžื•ื“ ื‘ื‘ืœื™") + "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™", "ืชืœืžื•ื“ ื™ืจื•ืฉืœื™ื" -> listOf("ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™") + "ืชื ืš", "ืชื \"ืš", "ืชื ืดืš" -> listOf("ืชื \"ืš") + "ืฉื•ืช", "ืฉื•\"ืช", "ืฉื•ืดืช" -> listOf("ืฉื•\"ืช") else -> listOf(cleaned) } } @@ -208,7 +208,7 @@ class DatabaseGenerator( private fun normalizeBookTitle(rawTitle: String): String { val base = normalizeHebrewLabel(rawTitle) return when (base) { - "ืชื ืš", "ืชื \"ืš" -> "ืชื ืดืš" + "ืชื ืš", "ืชื \"ืš" -> "ืชื \"ืš" else -> base } } 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 e9be1cd4..3800c001 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 @@ -284,10 +284,20 @@ private fun findSourceCategories(conn: Connection, pattern: String): List + val sql = "UPDATE book SET title = ? WHERE $STRIP_TITLE_PUNCT_SQL = ?" + val n = conn.prepareStatement(sql).use { stmt -> stmt.setString(1, newTitle) - stmt.setString(2, oldTitle) + stmt.setString(2, stripTitlePunct(oldTitle)) stmt.executeUpdate() } if (n > 0) logger.i { "Renamed book '$oldTitle' -> '$newTitle' ($n rows)" } 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..a152ec88 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 @@ -138,7 +138,7 @@ 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, 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): List { + if (parts.isEmpty()) return parts + val flattened = ArrayList(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, heTitle: String): String = diff --git a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportText.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportText.kt index 69d54f52..fb7a7d24 100644 --- a/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportText.kt +++ b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SefariaImportText.kt @@ -2,7 +2,9 @@ package io.github.kdroidfilter.seforimlibrary.sefariasqlite internal fun sanitizeFolder(name: String?): String { if (name.isNullOrBlank()) return "" - return name.replace("\"", "ืด").trim() + return name + .replace("ืด", "\"") + .trim() } // Legacy Otzar HaChochma style/format markers that Sefaria did not strip when From 24db10a879107dc2663b9cdd3d2f430e50def803 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Mon, 1 Jun 2026 00:15:51 +0300 Subject: [PATCH 05/29] fix(sefariasqlite): correct Shabbat commentator and set Ramban for Chumash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shabbat pointed to "ืชื•ืกืคื•ืช ืขืœ ื‘ื‘ื ื‘ืชืจื" instead of "ืชื•ืกืคื•ืช ืขืœ ืฉื‘ืช" (copy-paste error). - Replace Radak with Ramban in Bereshit, and add Ramban to all five books of the Torah (Shemot, Vayikra, Bamidbar, Devarim). All referenced titles verified to exist in the generated DB after title-key normalization. --- .../jvmMain/resources/default_commentators.json | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json index 6efe80cc..70371362 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": [ "ืจืฉื™ ืขืœ ื‘ืจืืฉื™ืช", - "ืจื“ืง ืขืœ ื‘ืจืืฉื™ืช" + "ืจืžื‘ืŸ ืขืœ ื‘ืจืืฉื™ืช" ] }, { @@ -102,7 +103,8 @@ { "book": "ื“ื‘ืจื™ื", "commentators": [ - "ืจืฉื™ ืขืœ ื“ื‘ืจื™ื" + "ืจืฉื™ ืขืœ ื“ื‘ืจื™ื", + "ืจืžื‘ืŸ ืขืœ ื“ื‘ืจื™ื" ] }, { @@ -132,7 +134,8 @@ { "book": "ื•ื™ืงืจื", "commentators": [ - "ืจืฉื™ ืขืœ ื•ื™ืงืจื" + "ืจืฉื™ ืขืœ ื•ื™ืงืจื", + "ืจืžื‘ืŸ ืขืœ ื•ื™ืงืจื" ] }, { @@ -514,7 +517,7 @@ "book": "ืฉื‘ืช", "commentators": [ "ืจืฉื™ ืขืœ ืฉื‘ืช", - "ืชื•ืกืคื•ืช ืขืœ ื‘ื‘ื ื‘ืชืจื" + "ืชื•ืกืคื•ืช ืขืœ ืฉื‘ืช" ] }, { @@ -583,7 +586,8 @@ { "book": "ืฉืžื•ืช", "commentators": [ - "ืจืฉื™ ืขืœ ืฉืžื•ืช" + "ืจืฉื™ ืขืœ ืฉืžื•ืช", + "ืจืžื‘ืŸ ืขืœ ืฉืžื•ืช" ] }, { From 54d55d502d5c90ce7ccaddca117a2df1403d629b Mon Sep 17 00:00:00 2001 From: batsheva Date: Thu, 28 May 2026 08:04:53 +0300 Subject: [PATCH 06/29] =?UTF-8?q?feat(sefariasqlite):=20seed=20generation?= =?UTF-8?q?=20table=20from=20=D7=A1=D7=93=D7=A8=20=D7=94=D7=93=D7=95=D7=A8?= =?UTF-8?q?=D7=95=D7=AA.csv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `generation` table and `book_generation` junction, seeded during the renameCategories post-process from otzaria-library/ForDB/ืกื“ืจ ื”ื“ื•ืจื•ืช.csv. Junction kept outside PatchTables/LogicalContentHasher so delta-update clients are unaffected โ€” IDs are rowids, only `name` is a stable external key. Three-tier title matching (exact โ†’ punct-strip โ†’ TRIM) catches CSV titles that use bare-acronym forms and DB titles imported with stray whitespace. Caveat: only books present at this pipeline stage are linked. The appendOtzaria stage runs AFTER this one, so Otzaria-added books stay unlinked โ€” this is intentional, since CSV rows encode per-book generations that can legitimately diverge across books sharing an author (e.g. the empty-author bucket, Abarbanel on the ืจืืฉื•ื ื™ื/ืื—ืจื•ื ื™ื border), and any transitive author-level propagation would silently flatten those. --- .../seforimlibrary/db/Database.sq | 28 ++++ .../RenameCategoriesPostProcess.kt | 141 +++++++++++++++++- 2 files changed, 168 insertions(+), 1 deletion(-) 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..1680f540 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 @@ -207,6 +209,32 @@ 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 +-- during the renameCategories post-process. Intentionally NOT registered in +-- PatchTables / LogicalContentHasher: existing client DBs predate it, and the +-- delta-update applier does not run Schema.create, so listing it there would +-- break INSERTs into a missing table on old clients. Schema.create populates +-- the empty table on app init for older DBs; data is filled only on a full +-- rebuild. IDs are SQLite rowids (not IdAllocator-stable) โ€” only `name` is +-- meaningful as an external key. +CREATE TABLE IF NOT EXISTS generation ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE +); + +CREATE INDEX IF NOT EXISTS idx_generation_name ON generation(name); + +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 +); + +CREATE INDEX IF NOT EXISTS idx_book_generation_book ON book_generation(bookId); +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/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 3800c001..a1b8e92a 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 @@ -7,6 +7,7 @@ import java.nio.charset.StandardCharsets import java.nio.file.Paths import java.sql.Connection import java.sql.DriverManager +import java.sql.PreparedStatement import kotlin.io.path.exists import kotlin.system.exitProcess @@ -19,6 +20,7 @@ import kotlin.system.exitProcess * - ืชื™ืงื™ื•ืช.csv `old,new` โ€” category renames (exact match, prefix fallback) * - ืกืคืจื™ื.csv `old,new` โ€” book title renames (exact match) * - Moving files.csv `name,sourcePath,destPath` (simple CSV; embedded newlines in quoted fields are not supported) + * - ืกื“ืจ ื”ื“ื•ืจื•ืช.csv `ืฉื ืกืคืจ,ืงื‘ื•ืฆืช ื“ื•ืจ` (with header) โ€” seeds generation table and links books * * The release zip does not include ForDB/, so the CSVs are fetched directly * from raw.githubusercontent.com at task start. Download failures are @@ -49,6 +51,7 @@ private const val FOR_DB_BASE = "https://raw.githubusercontent.com/Otzaria/otzar private const val CATEGORY_RENAMES_URL = "$FOR_DB_BASE/%D7%AA%D7%99%D7%A7%D7%99%D7%95%D7%AA.csv" private const val BOOK_RENAMES_URL = "$FOR_DB_BASE/%D7%A1%D7%A4%D7%A8%D7%99%D7%9D.csv" private const val BOOK_MOVES_URL = "$FOR_DB_BASE/Moving%20files.csv" +private const val GENERATIONS_URL = "$FOR_DB_BASE/%D7%A1%D7%93%D7%A8%20%D7%94%D7%93%D7%95%D7%A8%D7%95%D7%AA.csv" fun main(args: Array) { Logger.setMinSeverity(Severity.Info) @@ -72,6 +75,7 @@ fun main(args: Array) { val categoryRenames: List> = parsePairs(downloadCsv(CATEGORY_RENAMES_URL, logger)) val bookRenames: List> = parsePairs(downloadCsv(BOOK_RENAMES_URL, logger)) val bookMoves: List = parseBookMoves(downloadCsv(BOOK_MOVES_URL, logger), logger) + val generations: List> = parseGenerations(downloadCsv(GENERATIONS_URL, logger), logger) try { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> @@ -118,11 +122,24 @@ fun main(args: Array) { } logger.i { "Book moves complete. Moved: $totalMoved books, Failures: $moveFailures" } + val genResult = try { + applyGenerations(conn, generations, logger) + } catch (e: Exception) { + logger.w(e) { "Generation seeding failed; skipping section" } + GenerationApplyResult(0, 0, generations.size) + } + logger.i { + "Generations complete. Seeded: ${genResult.generationsCreated}, " + + "book links: ${genResult.linksCreated}, unmatched titles: ${genResult.unmatched}" + } + conn.commit() logger.i { "Post-process done: categories renamed=$totalRenamed merged=$totalMerged " + "(failures=$categoryFailures); books renamed=$totalBookRenamed " + - "(failures=$bookRenameFailures); books moved=$totalMoved (failures=$moveFailures)" + "(failures=$bookRenameFailures); books moved=$totalMoved (failures=$moveFailures); " + + "generations seeded=${genResult.generationsCreated} " + + "(book links=${genResult.linksCreated}, unmatched=${genResult.unmatched})" } } } catch (e: Exception) { @@ -159,6 +176,128 @@ private fun parseBookMoves(lines: List, logger: Logger): List } } +/** + * `ืฉื ืกืคืจ,ืงื‘ื•ืฆืช ื“ื•ืจ` rows. Drops the header row if its first field contains + * "ืฉื ืกืคืจ"; otherwise treats every row as data (with a warning) so a + * header-less upload doesn't silently lose row 0. + */ +private fun parseGenerations(lines: List, logger: Logger): List> { + if (lines.isEmpty()) return emptyList() + val isHeader = "ืฉื ืกืคืจ" in lines.first() + val body = if (isHeader) lines.drop(1) else { + logger.w { "ืกื“ืจ ื”ื“ื•ืจื•ืช.csv: no header detected; treating first row as data" } + lines + } + return parsePairs(body) +} + +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`. Book titles are unique in this DB, so we + * take the single match; punctuation-strip fallback mirrors `renameBookTitle` + * for CSVs that use the bare form. INSERT OR IGNORE keeps re-runs idempotent. + * + * Caveat: only books present in the DB at this point are linked. The + * appendOtzaria stage runs AFTER this one โ€” books it adds stay unlinked. + */ +private fun applyGenerations( + conn: Connection, + rows: List>, + 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() + conn.createStatement().use { st -> + st.executeQuery("SELECT id, name FROM generation").use { rs -> + while (rs.next()) nameToId[rs.getString(2)] = rs.getLong(1) + } + } + + var linksCreated = 0 + var unmatched = 0 + conn.prepareStatement("SELECT id FROM book WHERE title = ?").use { findExact -> + conn.prepareStatement("SELECT id FROM book WHERE $STRIP_TITLE_PUNCT_SQL = ?").use { findStripped -> + conn.prepareStatement("SELECT id FROM book WHERE TRIM(title) = ?").use { findTrimmed -> + 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(findExact, findStripped, findTrimmed, bookTitle, logger) + if (bookId == null) { + unmatched++ + logger.d { "Generation link: book '$bookTitle' not found; skipping" } + continue + } + linkStmt.setLong(1, bookId) + linkStmt.setLong(2, genId) + linksCreated += linkStmt.executeUpdate() + } + } + } + } + } + return GenerationApplyResult(generationsCreated, linksCreated, unmatched) +} + +// Titles are unique. Three-tier fallback: exact, then punctuation-strip (for +// CSVs that use the bare form like ืจื“ืง vs ืจื“ืดืง), then TRIM (for DB titles +// imported with stray leading/trailing whitespace from upstream sources). +// >1 result from a fallback means colliding normalized forms โ€” log and skip +// rather than silently over-link. +private fun findBookIdForGeneration( + findExact: PreparedStatement, + findStripped: PreparedStatement, + findTrimmed: PreparedStatement, + title: String, + logger: Logger, +): Long? { + findExact.setString(1, title) + findExact.executeQuery().use { rs -> + if (rs.next()) return rs.getLong(1) + } + findStripped.setString(1, stripTitlePunct(title)) + val stripMatches = collectIds(findStripped) + when (stripMatches.size) { + 1 -> return stripMatches.single() + in 2..Int.MAX_VALUE -> { + logger.w { "Generation link: '$title' has multiple punct-strip matches; skipping" } + return null + } + } + findTrimmed.setString(1, title.trim()) + val trimMatches = collectIds(findTrimmed) + return when (trimMatches.size) { + 0 -> null + 1 -> trimMatches.single() + else -> { + logger.w { "Generation link: '$title' has multiple TRIM matches; skipping" } + null + } + } +} + +private fun collectIds(stmt: PreparedStatement): List { + val out = ArrayList(2) + stmt.executeQuery().use { rs -> + while (rs.next() && out.size < 2) out += rs.getLong(1) + } + return out +} + private sealed class RenameResult { data class Renamed(val count: Int) : RenameResult() data class Merged(val booksMoved: Int) : RenameResult() From 62afd5045183e9c05018fdb25b77dc7eb4ddfdd1 Mon Sep 17 00:00:00 2001 From: batsheva Date: Thu, 28 May 2026 08:15:48 +0300 Subject: [PATCH 07/29] refactor(sefariasqlite): address PR #5 review - Drop redundant indexes: idx_generation_name (covered by UNIQUE constraint) and idx_book_generation_book (covered by composite PK leftmost prefix). - Rewrite applyGenerations to load books once into in-memory maps so the three-tier matcher is O(1) per CSV row instead of full table scans on REPLACE/TRIM expressions. - Treat exact-match like the fallbacks: skip with a warning on multiple matches. book.title is not UNIQUE in the schema, even if current data is. --- .../seforimlibrary/db/Database.sq | 5 +- .../RenameCategoriesPostProcess.kt | 100 ++++++++---------- 2 files changed, 44 insertions(+), 61 deletions(-) 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 1680f540..679062d0 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 @@ -222,8 +222,6 @@ CREATE TABLE IF NOT EXISTS generation ( name TEXT NOT NULL UNIQUE ); -CREATE INDEX IF NOT EXISTS idx_generation_name ON generation(name); - CREATE TABLE IF NOT EXISTS book_generation ( bookId INTEGER NOT NULL, generationId INTEGER NOT NULL, @@ -232,7 +230,8 @@ CREATE TABLE IF NOT EXISTS book_generation ( FOREIGN KEY (generationId) REFERENCES generation(id) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_book_generation_book ON book_generation(bookId); +-- 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 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 a1b8e92a..83e6de39 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 @@ -7,7 +7,6 @@ import java.nio.charset.StandardCharsets import java.nio.file.Paths import java.sql.Connection import java.sql.DriverManager -import java.sql.PreparedStatement import kotlin.io.path.exists import kotlin.system.exitProcess @@ -199,9 +198,10 @@ private data class GenerationApplyResult( /** * Seeds the `generation` table with distinct names and links each book to its - * generation via `book_generation`. Book titles are unique in this DB, so we - * take the single match; punctuation-strip fallback mirrors `renameBookTitle` - * for CSVs that use the bare form. INSERT OR IGNORE keeps re-runs idempotent. + * generation via `book_generation`. Loads books once into in-memory maps so + * the three-tier matching (exact / punct-strip / trim) is O(1) per CSV row + * instead of full table scans on REPLACE/TRIM expressions. INSERT OR IGNORE + * keeps re-runs idempotent. * * Caveat: only books present in the DB at this point are linked. The * appendOtzaria stage runs AFTER this one โ€” books it adds stay unlinked. @@ -228,75 +228,59 @@ private fun applyGenerations( } } + val books = ArrayList>() + 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 }) + val strippedMap = books.groupBy({ stripTitlePunct(it.second) }, { it.first }) + val trimmedMap = books.groupBy({ it.second.trim() }, { it.first }) + var linksCreated = 0 var unmatched = 0 - conn.prepareStatement("SELECT id FROM book WHERE title = ?").use { findExact -> - conn.prepareStatement("SELECT id FROM book WHERE $STRIP_TITLE_PUNCT_SQL = ?").use { findStripped -> - conn.prepareStatement("SELECT id FROM book WHERE TRIM(title) = ?").use { findTrimmed -> - 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(findExact, findStripped, findTrimmed, bookTitle, logger) - if (bookId == null) { - unmatched++ - logger.d { "Generation link: book '$bookTitle' not found; skipping" } - continue - } - linkStmt.setLong(1, bookId) - linkStmt.setLong(2, genId) - linksCreated += linkStmt.executeUpdate() - } - } + 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, strippedMap, trimmedMap, bookTitle, logger) + if (bookId == null) { + unmatched++ + logger.d { "Generation link: book '$bookTitle' not found; skipping" } + continue } + linkStmt.setLong(1, bookId) + linkStmt.setLong(2, genId) + linksCreated += linkStmt.executeUpdate() } } return GenerationApplyResult(generationsCreated, linksCreated, unmatched) } -// Titles are unique. Three-tier fallback: exact, then punctuation-strip (for -// CSVs that use the bare form like ืจื“ืง vs ืจื“ืดืง), then TRIM (for DB titles -// imported with stray leading/trailing whitespace from upstream sources). -// >1 result from a fallback means colliding normalized forms โ€” log and skip -// rather than silently over-link. +// Three-tier fallback: exact, then punctuation-strip (for CSVs that use the +// bare form like ืจื“ืง vs ืจื“ืดืง), then TRIM (for DB titles imported with stray +// leading/trailing whitespace from upstream sources). `book.title` is not +// UNIQUE in the schema, so any tier can return >1 โ€” log and skip rather than +// arbitrarily picking one. private fun findBookIdForGeneration( - findExact: PreparedStatement, - findStripped: PreparedStatement, - findTrimmed: PreparedStatement, + exactMap: Map>, + strippedMap: Map>, + trimmedMap: Map>, title: String, logger: Logger, ): Long? { - findExact.setString(1, title) - findExact.executeQuery().use { rs -> - if (rs.next()) return rs.getLong(1) - } - findStripped.setString(1, stripTitlePunct(title)) - val stripMatches = collectIds(findStripped) - when (stripMatches.size) { - 1 -> return stripMatches.single() - in 2..Int.MAX_VALUE -> { - logger.w { "Generation link: '$title' has multiple punct-strip matches; skipping" } - return null - } - } - findTrimmed.setString(1, title.trim()) - val trimMatches = collectIds(findTrimmed) - return when (trimMatches.size) { - 0 -> null - 1 -> trimMatches.single() - else -> { - logger.w { "Generation link: '$title' has multiple TRIM matches; skipping" } - null - } - } + exactMap[title]?.let { return pickOne(it, title, "exact", logger) } + strippedMap[stripTitlePunct(title)]?.let { return pickOne(it, title, "punct-strip", logger) } + trimmedMap[title.trim()]?.let { return pickOne(it, title, "TRIM", logger) } + return null } -private fun collectIds(stmt: PreparedStatement): List { - val out = ArrayList(2) - stmt.executeQuery().use { rs -> - while (rs.next() && out.size < 2) out += rs.getLong(1) +private fun pickOne(matches: List, title: String, tier: String, logger: Logger): Long? = + if (matches.size == 1) matches.single() + else { + logger.w { "Generation link: '$title' has multiple $tier matches; skipping" } + null } - return out -} private sealed class RenameResult { data class Renamed(val count: Int) : RenameResult() From 2922f280be287c94b8112b45e822d97b80e34881 Mon Sep 17 00:00:00 2001 From: batsheva Date: Thu, 28 May 2026 10:19:09 +0300 Subject: [PATCH 08/29] refactor(sefariasqlite): extract generation seeding to its own task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves generation seeding out of renameCategories into a new :sefariasqlite:seedGenerations task that runs after appendOtzaria. Same matcher, same CSV, same junction โ€” but the seed pass now sees Otzaria-stage books too. Per-book CSV granularity is preserved (no transitive author-level propagation that would flatten the empty-author bucket or ืื‘ืจื‘ื ืืœ's ืจืืฉื•ื ื™ื/ืื—ืจื•ื ื™ื split). Shared CSV helpers in RenameCategoriesPostProcess.kt (parsePairs, downloadCsv, stripTitlePunct, STRIP_TITLE_PUNCT_SQL, FOR_DB_BASE) promoted from private to internal so the new file reuses them without duplication. Local result: unlinked books drop from 1559 โ†’ 697. The Otzaria-curated sources are now nearly fully linked (OnYourWayToOtzaria 99 โ†’ 0, Dicta 658 โ†’ 121, ToratEmet 97 โ†’ 9). Remaining unlinked are books legitimately absent from the CSV. --- build.gradle.kts | 10 + generator/sefariasqlite/build.gradle.kts | 24 +++ .../RenameCategoriesPostProcess.kt | 135 +------------- .../SeedGenerationsPostProcess.kt | 176 ++++++++++++++++++ 4 files changed, 216 insertions(+), 129 deletions(-) create mode 100644 generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SeedGenerationsPostProcess.kt diff --git a/build.gradle.kts b/build.gradle.kts index b01c0b05..a07ff783 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") diff --git a/generator/sefariasqlite/build.gradle.kts b/generator/sefariasqlite/build.gradle.kts index 77257e66..352c1c46 100644 --- a/generator/sefariasqlite/build.gradle.kts +++ b/generator/sefariasqlite/build.gradle.kts @@ -74,6 +74,30 @@ tasks.register("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("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 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 83e6de39..85acf9a1 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 @@ -19,7 +19,6 @@ import kotlin.system.exitProcess * - ืชื™ืงื™ื•ืช.csv `old,new` โ€” category renames (exact match, prefix fallback) * - ืกืคืจื™ื.csv `old,new` โ€” book title renames (exact match) * - Moving files.csv `name,sourcePath,destPath` (simple CSV; embedded newlines in quoted fields are not supported) - * - ืกื“ืจ ื”ื“ื•ืจื•ืช.csv `ืฉื ืกืคืจ,ืงื‘ื•ืฆืช ื“ื•ืจ` (with header) โ€” seeds generation table and links books * * The release zip does not include ForDB/, so the CSVs are fetched directly * from raw.githubusercontent.com at task start. Download failures are @@ -46,11 +45,10 @@ import kotlin.system.exitProcess * Env alternatives: * SEFORIM_DB */ -private const val FOR_DB_BASE = "https://raw.githubusercontent.com/Otzaria/otzaria-library/main/ForDB" +internal const val FOR_DB_BASE = "https://raw.githubusercontent.com/Otzaria/otzaria-library/main/ForDB" private const val CATEGORY_RENAMES_URL = "$FOR_DB_BASE/%D7%AA%D7%99%D7%A7%D7%99%D7%95%D7%AA.csv" private const val BOOK_RENAMES_URL = "$FOR_DB_BASE/%D7%A1%D7%A4%D7%A8%D7%99%D7%9D.csv" private const val BOOK_MOVES_URL = "$FOR_DB_BASE/Moving%20files.csv" -private const val GENERATIONS_URL = "$FOR_DB_BASE/%D7%A1%D7%93%D7%A8%20%D7%94%D7%93%D7%95%D7%A8%D7%95%D7%AA.csv" fun main(args: Array) { Logger.setMinSeverity(Severity.Info) @@ -74,7 +72,6 @@ fun main(args: Array) { val categoryRenames: List> = parsePairs(downloadCsv(CATEGORY_RENAMES_URL, logger)) val bookRenames: List> = parsePairs(downloadCsv(BOOK_RENAMES_URL, logger)) val bookMoves: List = parseBookMoves(downloadCsv(BOOK_MOVES_URL, logger), logger) - val generations: List> = parseGenerations(downloadCsv(GENERATIONS_URL, logger), logger) try { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> @@ -121,24 +118,11 @@ fun main(args: Array) { } logger.i { "Book moves complete. Moved: $totalMoved books, Failures: $moveFailures" } - val genResult = try { - applyGenerations(conn, generations, logger) - } catch (e: Exception) { - logger.w(e) { "Generation seeding failed; skipping section" } - GenerationApplyResult(0, 0, generations.size) - } - logger.i { - "Generations complete. Seeded: ${genResult.generationsCreated}, " + - "book links: ${genResult.linksCreated}, unmatched titles: ${genResult.unmatched}" - } - conn.commit() logger.i { "Post-process done: categories renamed=$totalRenamed merged=$totalMerged " + "(failures=$categoryFailures); books renamed=$totalBookRenamed " + - "(failures=$bookRenameFailures); books moved=$totalMoved (failures=$moveFailures); " + - "generations seeded=${genResult.generationsCreated} " + - "(book links=${genResult.linksCreated}, unmatched=${genResult.unmatched})" + "(failures=$bookRenameFailures); books moved=$totalMoved (failures=$moveFailures)" } } } catch (e: Exception) { @@ -148,7 +132,7 @@ fun main(args: Array) { } /** `old,new` rows โ€” skips blanks and malformed lines. */ -private fun parsePairs(lines: List): List> = lines.mapNotNull { line -> +internal fun parsePairs(lines: List): List> = lines.mapNotNull { line -> val f = parseCsvLine(line).map { it.trim() } if (f.size >= 2 && f[0].isNotEmpty() && f[1].isNotEmpty()) f[0] to f[1] else null } @@ -175,113 +159,6 @@ private fun parseBookMoves(lines: List, logger: Logger): List } } -/** - * `ืฉื ืกืคืจ,ืงื‘ื•ืฆืช ื“ื•ืจ` rows. Drops the header row if its first field contains - * "ืฉื ืกืคืจ"; otherwise treats every row as data (with a warning) so a - * header-less upload doesn't silently lose row 0. - */ -private fun parseGenerations(lines: List, logger: Logger): List> { - if (lines.isEmpty()) return emptyList() - val isHeader = "ืฉื ืกืคืจ" in lines.first() - val body = if (isHeader) lines.drop(1) else { - logger.w { "ืกื“ืจ ื”ื“ื•ืจื•ืช.csv: no header detected; treating first row as data" } - lines - } - return parsePairs(body) -} - -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`. Loads books once into in-memory maps so - * the three-tier matching (exact / punct-strip / trim) is O(1) per CSV row - * instead of full table scans on REPLACE/TRIM expressions. INSERT OR IGNORE - * keeps re-runs idempotent. - * - * Caveat: only books present in the DB at this point are linked. The - * appendOtzaria stage runs AFTER this one โ€” books it adds stay unlinked. - */ -private fun applyGenerations( - conn: Connection, - rows: List>, - 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() - 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>() - 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 }) - val strippedMap = books.groupBy({ stripTitlePunct(it.second) }, { it.first }) - val trimmedMap = books.groupBy({ it.second.trim() }, { it.first }) - - var linksCreated = 0 - var unmatched = 0 - 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, strippedMap, trimmedMap, bookTitle, logger) - if (bookId == null) { - unmatched++ - logger.d { "Generation link: book '$bookTitle' not found; skipping" } - continue - } - linkStmt.setLong(1, bookId) - linkStmt.setLong(2, genId) - linksCreated += linkStmt.executeUpdate() - } - } - return GenerationApplyResult(generationsCreated, linksCreated, unmatched) -} - -// Three-tier fallback: exact, then punctuation-strip (for CSVs that use the -// bare form like ืจื“ืง vs ืจื“ืดืง), then TRIM (for DB titles imported with stray -// leading/trailing whitespace from upstream sources). `book.title` is not -// UNIQUE in the schema, so any tier can return >1 โ€” log and skip rather than -// arbitrarily picking one. -private fun findBookIdForGeneration( - exactMap: Map>, - strippedMap: Map>, - trimmedMap: Map>, - title: String, - logger: Logger, -): Long? { - exactMap[title]?.let { return pickOne(it, title, "exact", logger) } - strippedMap[stripTitlePunct(title)]?.let { return pickOne(it, title, "punct-strip", logger) } - trimmedMap[title.trim()]?.let { return pickOne(it, title, "TRIM", logger) } - return null -} - -private fun pickOne(matches: List, title: String, tier: String, logger: Logger): Long? = - if (matches.size == 1) matches.single() - else { - logger.w { "Generation link: '$title' has multiple $tier matches; skipping" } - null - } - private sealed class RenameResult { data class Renamed(val count: Int) : RenameResult() data class Merged(val booksMoved: Int) : RenameResult() @@ -410,10 +287,10 @@ private fun findSourceCategories(conn: Connection, pattern: String): List = try { +internal fun downloadCsv(url: String, logger: Logger): List = try { val conn = URI(url).toURL().openConnection().apply { connectTimeout = 10_000 readTimeout = 30_000 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..6d2c3cbf --- /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/ืกื“ืจ ื”ื“ื•ืจื•ืช.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 non-fatal: a GitHub outage logs a warning and the + * pipeline continues with no seeding (preferable to a red build). + * + * Usage: + * ./gradlew :sefariasqlite:seedGenerations -PseforimDb=/path/to/seforim.db + * + * Env alternatives: + * SEFORIM_DB + */ +private const val GENERATIONS_URL = "$FOR_DB_BASE/%D7%A1%D7%93%D7%A8%20%D7%94%D7%93%D7%95%D7%A8%D7%95%D7%AA.csv" + +fun main(args: Array) { + 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(downloadCsv(GENERATIONS_URL, logger), logger) + + try { + DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> + conn.autoCommit = false + + val result = try { + applyGenerations(conn, rows, logger) + } catch (e: Exception) { + logger.w(e) { "Generation seeding failed; skipping section" } + GenerationApplyResult(0, 0, rows.size) + } + conn.commit() + + logger.i { + "Generations done: seeded=${result.generationsCreated} " + + "book links=${result.linksCreated} unmatched=${result.unmatched}" + } + } + } catch (e: Exception) { + logger.e(e) { "Failed to open or commit DB; aborting" } + exitProcess(1) + } +} + +/** + * `ืฉื ืกืคืจ,ืงื‘ื•ืฆืช ื“ื•ืจ` rows. Drops the header row if its first field contains + * "ืฉื ืกืคืจ"; otherwise treats every row as data (with a warning) so a + * header-less upload doesn't silently lose row 0. + */ +private fun parseGenerations(lines: List, logger: Logger): List> { + if (lines.isEmpty()) return emptyList() + val isHeader = "ืฉื ืกืคืจ" in lines.first() + val body = if (isHeader) lines.drop(1) else { + logger.w { "ืกื“ืจ ื”ื“ื•ืจื•ืช.csv: no header detected; treating first row as data" } + lines + } + return parsePairs(body) +} + +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`. Loads books once into in-memory maps so + * the three-tier matching (exact / punct-strip / TRIM) is O(1) per CSV row + * instead of full table scans on REPLACE/TRIM expressions. INSERT OR IGNORE + * keeps re-runs idempotent. + */ +private fun applyGenerations( + conn: Connection, + rows: List>, + 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() + 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>() + 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 }) + val strippedMap = books.groupBy({ stripTitlePunct(it.second) }, { it.first }) + val trimmedMap = books.groupBy({ it.second.trim() }, { it.first }) + + var linksCreated = 0 + var unmatched = 0 + 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, strippedMap, trimmedMap, bookTitle, logger) + if (bookId == null) { + unmatched++ + logger.d { "Generation link: book '$bookTitle' not found; skipping" } + continue + } + linkStmt.setLong(1, bookId) + linkStmt.setLong(2, genId) + linksCreated += linkStmt.executeUpdate() + } + } + return GenerationApplyResult(generationsCreated, linksCreated, unmatched) +} + +// Three-tier fallback: exact, then punctuation-strip (for CSVs that use the +// bare form like ืจื“ืง vs ืจื“ืดืง), then TRIM (for DB titles imported with stray +// leading/trailing whitespace from upstream sources). `book.title` is not +// UNIQUE in the schema, so any tier can return >1 โ€” log and skip rather than +// arbitrarily picking one. +private fun findBookIdForGeneration( + exactMap: Map>, + strippedMap: Map>, + trimmedMap: Map>, + title: String, + logger: Logger, +): Long? { + exactMap[title]?.let { return pickOne(it, title, "exact", logger) } + strippedMap[stripTitlePunct(title)]?.let { return pickOne(it, title, "punct-strip", logger) } + trimmedMap[title.trim()]?.let { return pickOne(it, title, "TRIM", logger) } + return null +} + +private fun pickOne(matches: List, title: String, tier: String, logger: Logger): Long? = + if (matches.size == 1) matches.single() + else { + logger.w { "Generation link: '$title' has multiple $tier matches; skipping" } + null + } From 0b1984c81f3e68a5611a9a24dce65f3bff64180b Mon Sep 17 00:00:00 2001 From: batsheva Date: Thu, 28 May 2026 10:39:33 +0300 Subject: [PATCH 09/29] fix(sefariasqlite): rollback on partial seed failure If applyGenerations threw mid-loop, the outer catch swallowed the exception and conn.commit() still ran, persisting partial book_generation inserts. Now commit lives inside the try and any failure triggers an explicit rollback before the run continues with a logged warning. Addresses gemini review on PR #5. RenameCategoriesPostProcess.kt does not need the same change: its per-rule loops catch failures by design, and any uncaught exception escapes through conn.use { } without ever reaching commit(). --- .../sefariasqlite/SeedGenerationsPostProcess.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 6d2c3cbf..f30d7fc6 100644 --- 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 @@ -53,12 +53,14 @@ fun main(args: Array) { conn.autoCommit = false val result = try { - applyGenerations(conn, rows, logger) + val res = applyGenerations(conn, rows, logger) + conn.commit() + res } catch (e: Exception) { + runCatching { conn.rollback() }.onFailure { logger.w(it) { "Rollback failed" } } logger.w(e) { "Generation seeding failed; skipping section" } GenerationApplyResult(0, 0, rows.size) } - conn.commit() logger.i { "Generations done: seeded=${result.generationsCreated} " + From 48d095e5bc5f82e2ef66eb8dbaf29229d3ce93c5 Mon Sep 17 00:00:00 2001 From: batsheva Date: Sun, 31 May 2026 14:03:12 +0300 Subject: [PATCH 10/29] fix(generations): add generation tables to delta pipeline and fix fallback order --- .../common/patch/LogicalContentHasher.kt | 2 ++ .../seforimlibrary/common/patch/PatchTables.kt | 14 ++++++++------ .../sefariasqlite/SeedGenerationsPostProcess.kt | 14 ++++++++------ 3 files changed, 18 insertions(+), 12 deletions(-) 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/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 = 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 = 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/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SeedGenerationsPostProcess.kt b/generator/sefariasqlite/src/jvmMain/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/SeedGenerationsPostProcess.kt index f30d7fc6..6def8079 100644 --- 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 @@ -152,11 +152,13 @@ private fun applyGenerations( return GenerationApplyResult(generationsCreated, linksCreated, unmatched) } -// Three-tier fallback: exact, then punctuation-strip (for CSVs that use the -// bare form like ืจื“ืง vs ืจื“ืดืง), then TRIM (for DB titles imported with stray -// leading/trailing whitespace from upstream sources). `book.title` is not -// UNIQUE in the schema, so any tier can return >1 โ€” log and skip rather than -// arbitrarily picking one. +// Three-tier fallback: exact, then TRIM (for DB titles imported with stray +// leading/trailing whitespace from upstream sources), then punctuation-strip +// (for CSVs that use the bare form like ืจื“ืง vs ืจื“ืดืง). TRIM before punct-strip +// so a title like "ืจื“ืง " resolves to the unique "ืจื“ืง" entry rather than +// hitting the broader punct-strip tier which matches both "ืจื“ืง" and "ืจื“ืดืง". +// `book.title` is not UNIQUE in the schema, so any tier can return >1 โ€” log +// and skip rather than arbitrarily picking one. private fun findBookIdForGeneration( exactMap: Map>, strippedMap: Map>, @@ -165,8 +167,8 @@ private fun findBookIdForGeneration( logger: Logger, ): Long? { exactMap[title]?.let { return pickOne(it, title, "exact", logger) } - strippedMap[stripTitlePunct(title)]?.let { return pickOne(it, title, "punct-strip", logger) } trimmedMap[title.trim()]?.let { return pickOne(it, title, "TRIM", logger) } + strippedMap[stripTitlePunct(title)]?.let { return pickOne(it, title, "punct-strip", logger) } return null } From 437d3abf0a633e4e60cb1d1d3a3e202874e102ab Mon Sep 17 00:00:00 2001 From: y-ploni <7353755@gmail.com> Date: Tue, 2 Jun 2026 11:11:08 +0300 Subject: [PATCH 11/29] Fix code comments --- .../kdroidfilter/seforimlibrary/db/Database.sq | 18 +++++++++++------- .../common/patch/PatchPipelineCli.kt | 16 +++++++++------- 2 files changed, 20 insertions(+), 14 deletions(-) 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 679062d0..8a3eb027 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 @@ -210,13 +210,17 @@ 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 --- during the renameCategories post-process. Intentionally NOT registered in --- PatchTables / LogicalContentHasher: existing client DBs predate it, and the --- delta-update applier does not run Schema.create, so listing it there would --- break INSERTs into a missing table on old clients. Schema.create populates --- the empty table on app init for older DBs; data is filled only on a full --- rebuild. IDs are SQLite rowids (not IdAllocator-stable) โ€” only `name` is --- meaningful as an external key. +-- 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 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) { } 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) { 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." } } } From 731e1ab320b5ea12e570912641e6e67017d7e166 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Mon, 1 Jun 2026 13:38:04 +0300 Subject: [PATCH 12/29] fix(sefariasqlite): use full Tosafot Yom Tov instead of Ikkar in Mishnah MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 'ืขื™ืงืจ ืชื•ืกืคื•ืช ื™ื•ื ื˜ื•ื‘ ืขืœ ืžืฉื ื” X' with the full 'ืชื•ืกืคื•ืช ื™ื•ื ื˜ื•ื‘ ืขืœ ืžืฉื ื” X' across all 63 mishnayot. All targets verified to exist in the DB. --- .../resources/default_commentators.json | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json index 70371362..cdf9e51b 100644 --- a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json +++ b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json @@ -617,441 +617,441 @@ "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": [ "ื‘ืจื˜ื ื•ืจื ืขืœ ืžืฉื ื” ืชืจื•ืžื•ืช", - "ืขื™ืงืจ ืชื•ืกืคื•ืช ื™ื•ื ื˜ื•ื‘ ืขืœ ืžืฉื ื” ืชืจื•ืžื•ืช" + "ืชื•ืกืคื•ืช ื™ื•ื ื˜ื•ื‘ ืขืœ ืžืฉื ื” ืชืจื•ืžื•ืช" ] }, { From 3e04c110c04e9e773dc4056b0f838c3686388bcd Mon Sep 17 00:00:00 2001 From: y-ploni <7353755@gmail.com> Date: Tue, 2 Jun 2026 20:31:32 +0300 Subject: [PATCH 13/29] fix(sefariasqlite): fail required rename rule downloads --- .../RenameCategoriesPostProcess.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) 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 85acf9a1..85ed033b 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 @@ -22,10 +22,7 @@ import kotlin.system.exitProcess * * The release zip does not include ForDB/, so the CSVs are fetched directly * from raw.githubusercontent.com at task start. Download failures are - * non-fatal: the corresponding section becomes a no-op (logged as a warning) - * and the rest of the run proceeds. This is intentional โ€” a GitHub outage - * should not break the CI build; an un-post-processed DB is preferable to a - * red pipeline. The warning lines are the signal to investigate. + * 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 @@ -69,9 +66,9 @@ fun main(args: Array) { // Rules downloaded from otzaria-library/ForDB/ at startup. // ืชื™ืงื™ื•ืช.csv and ืกืคืจื™ื.csv have no header; Moving files.csv has one. - val categoryRenames: List> = parsePairs(downloadCsv(CATEGORY_RENAMES_URL, logger)) - val bookRenames: List> = parsePairs(downloadCsv(BOOK_RENAMES_URL, logger)) - val bookMoves: List = parseBookMoves(downloadCsv(BOOK_MOVES_URL, logger), logger) + val categoryRenames: List> = parsePairs(downloadRequiredCsv(CATEGORY_RENAMES_URL, logger)) + val bookRenames: List> = parsePairs(downloadRequiredCsv(BOOK_RENAMES_URL, logger)) + val bookMoves: List = parseBookMoves(downloadRequiredCsv(BOOK_MOVES_URL, logger), logger) try { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> @@ -369,14 +366,24 @@ private fun resolveCategoryPath(conn: Connection, path: String): Long? { * then becomes a no-op and the rest of the run proceeds. */ internal fun downloadCsv(url: String, logger: Logger): List = try { + readCsvLines(url) +} catch (e: Exception) { + logger.w(e) { "Failed to download $url; skipping section" } + emptyList() +} + +internal fun downloadRequiredCsv(url: String, logger: Logger): List = try { + readCsvLines(url) +} catch (e: Exception) { + logger.e(e) { "Failed to download required CSV $url; aborting" } + throw IllegalStateException("Failed to download required CSV: $url", e) +} + +private fun readCsvLines(url: String): List { val conn = URI(url).toURL().openConnection().apply { connectTimeout = 10_000 readTimeout = 30_000 } val lines = conn.getInputStream().use { it.reader(StandardCharsets.UTF_8).readLines() } - if (lines.isEmpty()) lines else listOf(lines.first().removePrefix("\uFEFF")) + lines.drop(1) -} catch (e: Exception) { - logger.w(e) { "Failed to download $url; skipping section" } - emptyList() + return if (lines.isEmpty()) lines else listOf(lines.first().removePrefix("\uFEFF")) + lines.drop(1) } - From b2abc63e487a88e8247335300b03104225c6ff62 Mon Sep 17 00:00:00 2001 From: y-ploni <7353755@gmail.com> Date: Tue, 2 Jun 2026 20:32:09 +0300 Subject: [PATCH 14/29] refactor(sefariasqlite): encode ForDB CSV urls at runtime --- .../sefariasqlite/RenameCategoriesPostProcess.kt | 16 +++++++++++++--- .../sefariasqlite/SeedGenerationsPostProcess.kt | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) 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 85ed033b..d975767a 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 @@ -3,6 +3,7 @@ package io.github.kdroidfilter.seforimlibrary.sefariasqlite import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity import java.net.URI +import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.nio.file.Paths import java.sql.Connection @@ -43,9 +44,18 @@ import kotlin.system.exitProcess * SEFORIM_DB */ internal const val FOR_DB_BASE = "https://raw.githubusercontent.com/Otzaria/otzaria-library/main/ForDB" -private const val CATEGORY_RENAMES_URL = "$FOR_DB_BASE/%D7%AA%D7%99%D7%A7%D7%99%D7%95%D7%AA.csv" -private const val BOOK_RENAMES_URL = "$FOR_DB_BASE/%D7%A1%D7%A4%D7%A8%D7%99%D7%9D.csv" -private const val BOOK_MOVES_URL = "$FOR_DB_BASE/Moving%20files.csv" +internal val FOR_DB_CSV_FILES = mapOf( + "categoryRenames" to "ืชื™ืงื™ื•ืช.csv", + "bookRenames" to "ืกืคืจื™ื.csv", + "bookMoves" to "Moving files.csv", + "generations" to "ืกื“ืจ ื”ื“ื•ืจื•ืช.csv", +) +private val CATEGORY_RENAMES_URL = forDbUrl(FOR_DB_CSV_FILES.getValue("categoryRenames")) +private val BOOK_RENAMES_URL = forDbUrl(FOR_DB_CSV_FILES.getValue("bookRenames")) +private val BOOK_MOVES_URL = forDbUrl(FOR_DB_CSV_FILES.getValue("bookMoves")) + +internal fun forDbUrl(fileName: String): String = + "$FOR_DB_BASE/" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20") fun main(args: Array) { Logger.setMinSeverity(Severity.Info) 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 index 6def8079..833ca996 100644 --- 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 @@ -27,7 +27,7 @@ import kotlin.system.exitProcess * Env alternatives: * SEFORIM_DB */ -private const val GENERATIONS_URL = "$FOR_DB_BASE/%D7%A1%D7%93%D7%A8%20%D7%94%D7%93%D7%95%D7%A8%D7%95%D7%AA.csv" +private val GENERATIONS_URL = forDbUrl(FOR_DB_CSV_FILES.getValue("generations")) fun main(args: Array) { Logger.setMinSeverity(Severity.Info) From b5dbfa37b0ab52a4ecf7f6a781ed63d6ca54693f Mon Sep 17 00:00:00 2001 From: y-ploni <7353755@gmail.com> Date: Tue, 2 Jun 2026 20:32:29 +0300 Subject: [PATCH 15/29] fix(sefariasqlite): fail required generation download --- .../sefariasqlite/SeedGenerationsPostProcess.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 833ca996..79516b8f 100644 --- 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 @@ -18,8 +18,8 @@ import kotlin.system.exitProcess * empty-author bucket where books legitimately span eras, ืื‘ืจื‘ื ืืœ's * ืจืืฉื•ื ื™ื/ืื—ืจื•ื ื™ื split). * - * Download failures are non-fatal: a GitHub outage logs a warning and the - * pipeline continues with no seeding (preferable to a red build). + * Download failures are fatal because silently skipping generation seeding can + * produce an invalid DB delta. * * Usage: * ./gradlew :sefariasqlite:seedGenerations -PseforimDb=/path/to/seforim.db @@ -46,7 +46,7 @@ fun main(args: Array) { logger.i { "Seeding generations in $dbPath" } - val rows = parseGenerations(downloadCsv(GENERATIONS_URL, logger), logger) + val rows = parseGenerations(downloadRequiredCsv(GENERATIONS_URL, logger), logger) try { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> From 92e9e682b7e572acafb7b49fb7ec72158958142d Mon Sep 17 00:00:00 2001 From: y-ploni <7353755@gmail.com> Date: Tue, 2 Jun 2026 20:34:00 +0300 Subject: [PATCH 16/29] refactor(sefariasqlite): share rule section processing --- .../RenameCategoriesPostProcess.kt | 75 ++++++++++--------- 1 file changed, 39 insertions(+), 36 deletions(-) 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 d975767a..e15d03eb 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 @@ -86,50 +86,30 @@ fun main(args: Array) { var totalRenamed = 0 var totalMerged = 0 - var categoryFailures = 0 - for ((oldName, newName) in categoryRenames) { - try { - when (val result = renameOrMergeCategory(conn, oldName, newName, logger)) { - is RenameResult.Renamed -> totalRenamed += result.count - is RenameResult.Merged -> totalMerged += result.booksMoved - is RenameResult.NotFound -> logger.w { "Category rename: '$oldName' not found; skipping" } - } - } catch (e: Exception) { - categoryFailures++ - logger.w(e) { "Category rename '$oldName' -> '$newName' failed; skipping" } + val categoryResult = runSection("Category renames", categoryRenames, logger) { (oldName, newName) -> + val result = renameOrMergeCategory(conn, oldName, newName, logger) + when (result) { + is RenameResult.Renamed -> totalRenamed += result.count + is RenameResult.Merged -> totalMerged += result.booksMoved + is RenameResult.NotFound -> logger.w { "Category rename: '$oldName' not found; skipping" } } + result.rows() } - logger.i { "Category processing complete. Renamed: $totalRenamed, Merged: $totalMerged books, Failures: $categoryFailures" } - - var totalBookRenamed = 0 - var bookRenameFailures = 0 - for ((oldTitle, newTitle) in bookRenames) { - try { - totalBookRenamed += renameBookTitle(conn, oldTitle, newTitle, logger) - } catch (e: Exception) { - bookRenameFailures++ - logger.w(e) { "Book rename '$oldTitle' -> '$newTitle' failed; skipping" } - } + + val bookRenameResult = runSection("Book renames", bookRenames, logger) { (oldTitle, newTitle) -> + renameBookTitle(conn, oldTitle, newTitle, logger) } - logger.i { "Book renames complete. Renamed: $totalBookRenamed books, Failures: $bookRenameFailures" } - - var totalMoved = 0 - var moveFailures = 0 - for (move in bookMoves) { - try { - if (applyBookMove(conn, move, logger)) totalMoved++ - } catch (e: Exception) { - moveFailures++ - logger.w(e) { "Book move '${move.name}' -> '${move.destPath}' failed; skipping" } - } + + val moveResult = runSection("Book moves", bookMoves, logger) { move -> + if (applyBookMove(conn, move, logger)) 1 else 0 } - logger.i { "Book moves complete. Moved: $totalMoved books, Failures: $moveFailures" } conn.commit() logger.i { "Post-process done: categories renamed=$totalRenamed merged=$totalMerged " + - "(failures=$categoryFailures); books renamed=$totalBookRenamed " + - "(failures=$bookRenameFailures); books moved=$totalMoved (failures=$moveFailures)" + "(failures=${categoryResult.failures}); books renamed=${bookRenameResult.applied} " + + "(failures=${bookRenameResult.failures}); books moved=${moveResult.applied} " + + "(failures=${moveResult.failures})" } } } catch (e: Exception) { @@ -166,10 +146,33 @@ private fun parseBookMoves(lines: List, logger: Logger): List } } +private data class SectionResult(val applied: Int, val failures: Int) + +private fun runSection(name: String, items: List, logger: Logger, apply: (T) -> Int): SectionResult { + var applied = 0 + var failures = 0 + for (item in items) { + try { + applied += apply(item) + } catch (e: Exception) { + failures++ + logger.w(e) { "$name failed for '$item'; skipping" } + } + } + logger.i { "$name: applied=$applied failures=$failures" } + return SectionResult(applied, failures) +} + 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 + } } /** From 7fd04265bff54e6d0d3f723fd85998f97daa3c44 Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Tue, 2 Jun 2026 20:40:18 +0300 Subject: [PATCH 17/29] fix(sefariasqlite): order Radak before Metzudot in Nakh defaults Move Radak ahead of Metzudat David/Tzion across all 24 Nakh books that include it, so the order is Rashi, Radak, Metzudat David, Metzudat Tzion. --- .../resources/default_commentators.json | 96 +++++++++---------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json index cdf9e51b..f7ff3143 100644 --- a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json +++ b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json @@ -86,18 +86,18 @@ "book": "ื“ื‘ืจื™ ื”ื™ืžื™ื ื", "commentators": [ "ืจืฉื™ ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื", + "ืจื“ืง ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ืืณ", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื", - "ืจื“ืง ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ืืณ" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื" ] }, { "book": "ื“ื‘ืจื™ ื”ื™ืžื™ื ื‘", "commentators": [ "ืจืฉื™ ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื‘", + "ืจื“ืง ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื‘", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื‘", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื‘", - "ืจื“ืง ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื‘" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื“ื‘ืจื™ ื”ื™ืžื™ื ื‘" ] }, { @@ -126,9 +126,9 @@ "book": "ื”ื•ืฉืข", "commentators": [ "ืจืฉื™ ืขืœ ื”ื•ืฉืข", + "ืจื“ืง ืขืœ ื”ื•ืฉืข", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื”ื•ืฉืข", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื”ื•ืฉืข", - "ืจื“ืง ืขืœ ื”ื•ืฉืข" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื”ื•ืฉืข" ] }, { @@ -149,27 +149,27 @@ "book": "ื–ื›ืจื™ื”", "commentators": [ "ืจืฉื™ ืขืœ ื–ื›ืจื™ื”", + "ืจื“ืง ืขืœ ื–ื›ืจื™ื”", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื–ื›ืจื™ื”", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื–ื›ืจื™ื”", - "ืจื“ืง ืขืœ ื–ื›ืจื™ื”" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื–ื›ืจื™ื”" ] }, { "book": "ื—ื‘ืงื•ืง", "commentators": [ "ืจืฉื™ ืขืœ ื—ื‘ืงื•ืง", + "ืจื“ืง ืขืœ ื—ื‘ืงื•ืง", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื—ื‘ืงื•ืง", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื—ื‘ืงื•ืง", - "ืจื“ืง ืขืœ ื—ื‘ืงื•ืง" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื—ื‘ืงื•ืง" ] }, { "book": "ื—ื’ื™", "commentators": [ "ืจืฉื™ ืขืœ ื—ื’ื™", + "ืจื“ืง ืขืœ ื—ื’ื™", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื—ื’ื™", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื—ื’ื™", - "ืจื“ืง ืขืœ ื—ื’ื™" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื—ื’ื™" ] }, { @@ -206,18 +206,18 @@ "book": "ื™ื”ื•ืฉืข", "commentators": [ "ืจืฉื™ ืขืœ ื™ื”ื•ืฉืข", + "ืจื“ืง ืขืœ ื™ื”ื•ืฉืข", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื™ื”ื•ืฉืข", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ื”ื•ืฉืข", - "ืจื“ืง ืขืœ ื™ื”ื•ืฉืข" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ื”ื•ืฉืข" ] }, { "book": "ื™ื•ืืœ", "commentators": [ "ืจืฉื™ ืขืœ ื™ื•ืืœ", + "ืจื“ืง ืขืœ ื™ื•ืืœ", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื™ื•ืืœ", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ื•ืืœ", - "ืจื“ืง ืขืœ ื™ื•ืืœ" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ื•ืืœ" ] }, { @@ -231,36 +231,36 @@ "book": "ื™ื•ื ื”", "commentators": [ "ืจืฉื™ ืขืœ ื™ื•ื ื”", + "ืจื“ืง ืขืœ ื™ื•ื ื”", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื™ื•ื ื”", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ื•ื ื”", - "ืจื“ืง ืขืœ ื™ื•ื ื”" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ื•ื ื”" ] }, { "book": "ื™ื—ื–ืงืืœ", "commentators": [ "ืจืฉื™ ืขืœ ื™ื—ื–ืงืืœ", + "ืจื“ืง ืขืœ ื™ื—ื–ืงืืœ", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื™ื—ื–ืงืืœ", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ื—ื–ืงืืœ", - "ืจื“ืง ืขืœ ื™ื—ื–ืงืืœ" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ื—ื–ืงืืœ" ] }, { "book": "ื™ืจืžื™ื”ื•", "commentators": [ "ืจืฉื™ ืขืœ ื™ืจืžื™ื”ื•", + "ืจื“ืง ืขืœ ื™ืจืžื™ืณ", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื™ืจืžื™ื”ื•", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ืจืžื™ื”ื•", - "ืจื“ืง ืขืœ ื™ืจืžื™ืณ" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ืจืžื™ื”ื•" ] }, { "book": "ื™ืฉืขื™ื”ื•", "commentators": [ "ืจืฉื™ ืขืœ ื™ืฉืขื™ื”ื•", + "ืจื“ืง ืขืœ ื™ืฉืขื™ืณ", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื™ืฉืขื™ื”ื•", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ืฉืขื™ื”ื•", - "ืจื“ืง ืขืœ ื™ืฉืขื™ืณ" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื™ืฉืขื™ื”ื•" ] }, { @@ -295,9 +295,9 @@ "book": "ืžื™ื›ื”", "commentators": [ "ืจืฉื™ ืขืœ ืžื™ื›ื”", + "ืจื“ืง ืขืœ ืžื™ื›ื”", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืžื™ื›ื”", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืžื™ื›ื”", - "ืจื“ืง ืขืœ ืžื™ื›ื”" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืžื™ื›ื”" ] }, { @@ -311,27 +311,27 @@ "book": "ืžืœืื›ื™", "commentators": [ "ืจืฉื™ ืขืœ ืžืœืื›ื™", + "ืจื“ืง ืขืœ ืžืœืื›ื™", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืžืœืื›ื™", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืžืœืื›ื™", - "ืจื“ืง ืขืœ ืžืœืื›ื™" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืžืœืื›ื™" ] }, { "book": "ืžืœื›ื™ื ื", "commentators": [ "ืจืฉื™ ืขืœ ืžืœื›ื™ื ื", + "ืจื“ืง ืขืœ ืžืœื›ื™ื ืืณ", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืžืœื›ื™ื ื", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืžืœื›ื™ื ื", - "ืจื“ืง ืขืœ ืžืœื›ื™ื ืืณ" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืžืœื›ื™ื ื" ] }, { "book": "ืžืœื›ื™ื ื‘", "commentators": [ "ืจืฉื™ ืขืœ ืžืœื›ื™ื ื‘", + "ืจื“ืง ืขืœ ืžืœื›ื™ื ื‘", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืžืœื›ื™ื ื‘", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืžืœื›ื™ื ื‘", - "ืจื“ืง ืขืœ ืžืœื›ื™ื ื‘" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืžืœื›ื™ื ื‘" ] }, { @@ -381,9 +381,9 @@ "book": "ื ื—ื•ื", "commentators": [ "ืจืฉื™ ืขืœ ื ื—ื•ื", + "ืจื“ืง ืขืœ ื ื—ื•ื", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ื ื—ื•ื", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื ื—ื•ื", - "ืจื“ืง ืขืœ ื ื—ื•ื" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ื ื—ื•ื" ] }, { @@ -426,9 +426,9 @@ "book": "ืขื•ื‘ื“ื™ื”", "commentators": [ "ืจืฉื™ ืขืœ ืขื•ื‘ื“ื™ื”", + "ืจื“ืง ืขืœ ืขื•ื‘ื“ื™ื”", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืขื•ื‘ื“ื™ื”", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืขื•ื‘ื“ื™ื”", - "ืจื“ืง ืขืœ ืขื•ื‘ื“ื™ื”" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืขื•ื‘ื“ื™ื”" ] }, { @@ -450,9 +450,9 @@ "book": "ืขืžื•ืก", "commentators": [ "ืจืฉื™ ืขืœ ืขืžื•ืก", + "ืจื“ืง ืขืœ ืขืžื•ืก", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืขืžื•ืก", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืขืžื•ืก", - "ืจื“ืง ืขืœ ืขืžื•ืก" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืขืžื•ืก" ] }, { @@ -473,9 +473,9 @@ "book": "ืฆืคื ื™ื”", "commentators": [ "ืจืฉื™ ืขืœ ืฆืคื ื™ื”", + "ืจื“ืง ืขืœ ืฆืคื ื™ื”", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืฆืคื ื™ื”", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืฆืคื ื™ื”", - "ืจื“ืง ืขืœ ืฆืคื ื™ื”" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืฆืคื ื™ื”" ] }, { @@ -552,9 +552,9 @@ "book": "ืฉื•ืคื˜ื™ื", "commentators": [ "ืจืฉื™ ืขืœ ืฉื•ืคื˜ื™ื", + "ืจื“ืง ืขืœ ืฉื•ืคื˜ื™ื", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืฉื•ืคื˜ื™ื", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืฉื•ืคื˜ื™ื", - "ืจื“ืง ืขืœ ืฉื•ืคื˜ื™ื" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืฉื•ืคื˜ื™ื" ] }, { @@ -569,18 +569,18 @@ "book": "ืฉืžื•ืืœ ื", "commentators": [ "ืจืฉื™ ืขืœ ืฉืžื•ืืœ ื", + "ืจื“ืง ืขืœ ืฉืžื•ืืœ ื", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืฉืžื•ืืœ ื", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืฉืžื•ืืœ ื", - "ืจื“ืง ืขืœ ืฉืžื•ืืœ ื" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืฉืžื•ืืœ ื" ] }, { "book": "ืฉืžื•ืืœ ื‘", "commentators": [ "ืจืฉื™ ืขืœ ืฉืžื•ืืœ ื‘", + "ืจื“ืง ืขืœ ืฉืžื•ืืœ ื‘", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืฉืžื•ืืœ ื‘", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืฉืžื•ืืœ ื‘", - "ืจื“ืง ืขืœ ืฉืžื•ืืœ ื‘" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืฉืžื•ืืœ ื‘" ] }, { @@ -594,9 +594,9 @@ "book": "ืชื”ื™ืœื™ื", "commentators": [ "ืจืฉื™ ืขืœ ืชื”ื™ืœื™ื", + "ืจื“ืง ืขืœ ืชื”ื™ืœื™ื", "ืžืฆื•ื“ืช ื“ื•ื“ ืขืœ ืชื”ื™ืœื™ื", - "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืชื”ื™ืœื™ื", - "ืจื“ืง ืขืœ ืชื”ื™ืœื™ื" + "ืžืฆื•ื“ืช ืฆื™ื•ืŸ ืขืœ ืชื”ื™ืœื™ื" ] }, { From c2f46c71ed9bd353c5180edef680276b81b79e3f Mon Sep 17 00:00:00 2001 From: palmoni5 Date: Wed, 3 Jun 2026 14:00:05 +0300 Subject: [PATCH 18/29] feat(sefariasqlite): expand default commentators for SA, Mishnah, Yerushalmi, Rambam - Shulchan Aruch OC: Magen Avraham, Taz, Mishnah Berurah, Shaar HaTziyun, Be'er HaGolah - Be'er HaGolah added to all four parts of Shulchan Aruch - Tiferet Yisrael (Yachin) added to all 63 mishnayot - Yerushalmi: Pnei Moshe, Korban HaEdah, Mareh HaPanim, Shayarei Korban (per availability) - Rambam: Magid Mishneh (where it exists) and Hasagot HaRaavad added to all halachot Shaar HaTziyun is not yet in the DB (will be added in a future content release); all other titles verified to exist in the DB after normalization. --- .../resources/default_commentators.json | 566 ++++++++++++------ 1 file changed, 394 insertions(+), 172 deletions(-) diff --git a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json index f7ff3143..dde53cbf 100644 --- a/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json +++ b/generator/sefariasqlite/src/jvmMain/resources/default_commentators.json @@ -524,28 +524,34 @@ "book": "ืฉื•ืœื—ืŸ ืขืจื•ืš, ืื‘ืŸ ื”ืขื–ืจ", "commentators": [ "ื—ืœืงืช ืžื—ื•ืงืง", - "ื‘ื™ืช ืฉืžื•ืืœ" + "ื‘ื™ืช ืฉืžื•ืืœ", + "ื‘ืืจ ื”ื’ื•ืœื” ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ืื‘ืŸ ื”ืขื–ืจ" ] }, { "book": "ืฉื•ืœื—ืŸ ืขืจื•ืš, ืื•ืจื— ื—ื™ื™ื", "commentators": [ "ืžื’ืŸ ืื‘ืจื”ื", - "ืžืฉื ื” ื‘ืจื•ืจื”" + "ื˜ื•ืจื™ ื–ื”ื‘ ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ืื•ืจื— ื—ื™ื™ื", + "ืžืฉื ื” ื‘ืจื•ืจื”", + "ืฉืขืจ ื”ืฆื™ื•ืŸ", + "ื‘ืืจ ื”ื’ื•ืœื” ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ืื•ืจื— ื—ื™ื™ื" ] }, { "book": "ืฉื•ืœื—ืŸ ืขืจื•ืš, ื—ื•ืฉืŸ ืžืฉืคื˜", "commentators": [ "ืฉืคืชื™ ื›ื”ืŸ ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ื—ื•ืฉืŸ ืžืฉืคื˜", - "ืžืื™ืจืช ืขื™ื ื™ื™ื ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ื—ื•ืฉืŸ ืžืฉืคื˜" + "ืžืื™ืจืช ืขื™ื ื™ื™ื ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ื—ื•ืฉืŸ ืžืฉืคื˜", + "ื‘ืืจ ื”ื’ื•ืœื” ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ื—ื•ืฉืŸ ืžืฉืคื˜" ] }, { "book": "ืฉื•ืœื—ืŸ ืขืจื•ืš, ื™ื•ืจื” ื“ืขื”", "commentators": [ "ืฉืคืชื™ ื›ื”ืŸ ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ื™ื•ืจื” ื“ืขื”", - "ื˜ื•ืจื™ ื–ื”ื‘ ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ื™ื•ืจื” ื“ืขื”" + "ื˜ื•ืจื™ ื–ื”ื‘ ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ื™ื•ืจื” ื“ืขื”", + "ื‘ืืจ ื”ื’ื•ืœื” ืขืœ ืฉื•ืœื—ืŸ ืขืจื•ืš ื™ื•ืจื” ื“ืขื”" ] }, { @@ -617,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": [ - "ื›ืกืฃ ืžืฉื ื” ืขืœ ืžืฉื ื” ืชื•ืจื”, ื”ืœื›ื•ืช ืžืœื›ื™ื ื•ืžืœื—ืžื•ืช" + "ื›ืกืฃ ืžืฉื ื” ืขืœ ืžืฉื ื” ืชื•ืจื”, ื”ืœื›ื•ืช ืžืœื›ื™ื ื•ืžืœื—ืžื•ืช", + "ื”ืฉื’ื•ืช ื”ืจืื‘ื“ ืขืœ ืžืฉื ื” ืชื•ืจื”, ื”ืœื›ื•ืช ืžืœื›ื™ื ื•ืžืœื—ืžื•ืช" ] }, { @@ -1577,7 +1755,9 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื‘ื™ืฆื”", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื‘ื™ืฆื”", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื‘ื™ืฆื”" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื‘ื™ืฆื”", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื‘ื™ืฆื”", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื‘ื™ืฆื”" ] }, { @@ -1598,7 +1778,9 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื’ื™ื˜ื™ืŸ", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื’ื™ื˜ื™ืŸ", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื’ื™ื˜ื™ืŸ" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื’ื™ื˜ื™ืŸ", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื’ื™ื˜ื™ืŸ", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื’ื™ื˜ื™ืŸ" ] }, { @@ -1619,7 +1801,9 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื—ื’ื™ื’ื”", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื—ื’ื™ื’ื”", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื—ื’ื™ื’ื”" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื—ื’ื™ื’ื”", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื—ื’ื™ื’ื”", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื—ื’ื™ื’ื”" ] }, { @@ -1633,14 +1817,18 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื‘ืžื•ืช", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื‘ืžื•ืช", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื‘ืžื•ืช" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื‘ืžื•ืช", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื‘ืžื•ืช", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื‘ืžื•ืช" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื•ืžื", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื•ืžื", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื•ืžื" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื•ืžื", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื•ืžื", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื™ื•ืžื" ] }, { @@ -1654,28 +1842,36 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื›ืชื•ื‘ื•ืช", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื›ืชื•ื‘ื•ืช", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื›ืชื•ื‘ื•ืช" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื›ืชื•ื‘ื•ืช", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื›ืชื•ื‘ื•ืช", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื›ืชื•ื‘ื•ืช" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื’ื™ืœื”", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื’ื™ืœื”", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื’ื™ืœื”" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื’ื™ืœื”", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื’ื™ืœื”", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื’ื™ืœื”" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื•ืขื“ ืงื˜ืŸ", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื•ืขื“ ืงื˜ืŸ", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื•ืขื“ ืงื˜ืŸ" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื•ืขื“ ืงื˜ืŸ", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื•ืขื“ ืงื˜ืŸ", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื•ืขื“ ืงื˜ืŸ" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื›ื•ืช", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื›ื•ืช", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื›ื•ืช" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื›ื•ืช", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื›ื•ืช", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืžื›ื•ืช" ] }, { @@ -1703,35 +1899,45 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื“ืจื™ื", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื“ืจื™ื", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื“ืจื™ื" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื“ืจื™ื", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื“ืจื™ื", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื“ืจื™ื" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื–ื™ืจ", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื–ื™ืจ", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื–ื™ืจ" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื–ื™ืจ", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื–ื™ืจ", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ื ื–ื™ืจ" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื˜ื”", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื˜ื”", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื˜ื”" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื˜ื”", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื˜ื”", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื˜ื”" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื›ื”", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื›ื”", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื›ื”" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื›ื”", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื›ื”", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื•ื›ื”" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื ื”ื“ืจื™ืŸ", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื ื”ื“ืจื™ืŸ", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื ื”ื“ืจื™ืŸ" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื ื”ื“ืจื™ืŸ", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื ื”ื“ืจื™ืŸ", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืกื ื”ื“ืจื™ืŸ" ] }, { @@ -1745,7 +1951,9 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืขื™ืจื•ื‘ื™ืŸ", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืขื™ืจื•ื‘ื™ืŸ", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืขื™ืจื•ื‘ื™ืŸ" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืขื™ืจื•ื‘ื™ืŸ", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืขื™ืจื•ื‘ื™ืŸ", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืขื™ืจื•ื‘ื™ืŸ" ] }, { @@ -1766,28 +1974,36 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืคืกื—ื™ื", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืคืกื—ื™ื", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืคืกื—ื™ื" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืคืกื—ื™ื", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืคืกื—ื™ื", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืคืกื—ื™ื" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืงื™ื“ื•ืฉื™ืŸ", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืงื™ื“ื•ืฉื™ืŸ", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืงื™ื“ื•ืฉื™ืŸ" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืงื™ื“ื•ืฉื™ืŸ", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืงื™ื“ื•ืฉื™ืŸ", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืงื™ื“ื•ืฉื™ืŸ" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืจืืฉ ื”ืฉื ื”", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืจืืฉ ื”ืฉื ื”", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืจืืฉ ื”ืฉื ื”" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืจืืฉ ื”ืฉื ื”", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืจืืฉ ื”ืฉื ื”", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืจืืฉ ื”ืฉื ื”" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ื•ืขื•ืช", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ื•ืขื•ืช", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ื•ืขื•ืช" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ื•ืขื•ืช", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ื•ืขื•ืช", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ื•ืขื•ืช" ] }, { @@ -1801,21 +2017,27 @@ "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ืช", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ืช", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ืช" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ืช", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ืช", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉื‘ืช" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉืงืœื™ื", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉืงืœื™ื", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉืงืœื™ื" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉืงืœื™ื", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉืงืœื™ื", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืฉืงืœื™ื" ] }, { "book": "ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืชืขื ื™ืช", "commentators": [ "ืคื ื™ ืžืฉื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืชืขื ื™ืช", - "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืชืขื ื™ืช" + "ืงืจื‘ืŸ ื”ืขื“ื” ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืชืขื ื™ืช", + "ืžืจืื” ื”ืคื ื™ื ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืชืขื ื™ืช", + "ืฉื™ื™ืจื™ ืงืจื‘ืŸ ืขืœ ืชืœืžื•ื“ ื™ืจื•ืฉืœืžื™ ืชืขื ื™ืช" ] }, { From 73a6a01e8861740f52d66203e868d708a51e2396 Mon Sep 17 00:00:00 2001 From: y-ploni Date: Sun, 7 Jun 2026 09:53:16 +0300 Subject: [PATCH 19/29] =?UTF-8?q?=D7=9E=D7=A2=D7=91=D7=A8=20=D7=9C=D7=94?= =?UTF-8?q?=D7=95=D7=A8=D7=93=D7=94=20=D7=9Erelease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RenameCategoriesPostProcess.kt | 116 ++++++++++++------ .../SeedGenerationsPostProcess.kt | 4 +- 2 files changed, 82 insertions(+), 38 deletions(-) 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 e15d03eb..a83bd086 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,12 +2,15 @@ package io.github.kdroidfilter.seforimlibrary.sefariasqlite import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity -import java.net.URI -import java.net.URLEncoder +import io.github.kdroidfilter.seforimlibrary.common.OptimizedHttpClient +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 @@ -21,9 +24,9 @@ import kotlin.system.exitProcess * - ืกืคืจื™ื.csv `old,new` โ€” book title renames (exact match) * - Moving files.csv `name,sourcePath,destPath` (simple CSV; embedded newlines in quoted fields are not supported) * - * The release zip does not include ForDB/, so the CSVs are fetched directly - * from raw.githubusercontent.com at task start. Download failures are - * fatal because silently skipping these rules can produce an invalid DB delta. + * 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 @@ -43,19 +46,16 @@ import kotlin.system.exitProcess * Env alternatives: * SEFORIM_DB */ -internal const val FOR_DB_BASE = "https://raw.githubusercontent.com/Otzaria/otzaria-library/main/ForDB" +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 "ืชื™ืงื™ื•ืช.csv", "bookRenames" to "ืกืคืจื™ื.csv", "bookMoves" to "Moving files.csv", "generations" to "ืกื“ืจ ื”ื“ื•ืจื•ืช.csv", ) -private val CATEGORY_RENAMES_URL = forDbUrl(FOR_DB_CSV_FILES.getValue("categoryRenames")) -private val BOOK_RENAMES_URL = forDbUrl(FOR_DB_CSV_FILES.getValue("bookRenames")) -private val BOOK_MOVES_URL = forDbUrl(FOR_DB_CSV_FILES.getValue("bookMoves")) - -internal fun forDbUrl(fileName: String): String = - "$FOR_DB_BASE/" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20") fun main(args: Array) { Logger.setMinSeverity(Severity.Info) @@ -76,9 +76,12 @@ fun main(args: Array) { // Rules downloaded from otzaria-library/ForDB/ at startup. // ืชื™ืงื™ื•ืช.csv and ืกืคืจื™ื.csv have no header; Moving files.csv has one. - val categoryRenames: List> = parsePairs(downloadRequiredCsv(CATEGORY_RENAMES_URL, logger)) - val bookRenames: List> = parsePairs(downloadRequiredCsv(BOOK_RENAMES_URL, logger)) - val bookMoves: List = parseBookMoves(downloadRequiredCsv(BOOK_MOVES_URL, logger), logger) + val categoryRenames: List> = + parsePairs(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("categoryRenames"), logger)) + val bookRenames: List> = + parsePairs(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("bookRenames"), logger)) + val bookMoves: List = + parseBookMoves(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("bookMoves"), logger), logger) try { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> @@ -373,30 +376,71 @@ private fun resolveCategoryPath(conn: Connection, path: String): Long? { return parentId } -/** - * Downloads a CSV and returns its lines (UTF-8, BOM stripped from line 0 if present). - * Returns an empty list on failure (logs a warning); the corresponding section - * then becomes a no-op and the rest of the run proceeds. - */ -internal fun downloadCsv(url: String, logger: Logger): List = try { - readCsvLines(url) -} catch (e: Exception) { - logger.w(e) { "Failed to download $url; skipping section" } - emptyList() +internal fun downloadRequiredForDbCsv(fileName: String, logger: Logger): List { + val lines = forDbReleaseCsvs(logger).getValue(fileName) + return if (lines.isEmpty()) lines else listOf(lines.first().removePrefix("\uFEFF")) + lines.drop(1) +} + +private var cachedForDbCsvs: Map>? = null + +private fun forDbReleaseCsvs(logger: Logger): Map> { + 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 } -internal fun downloadRequiredCsv(url: String, logger: Logger): List = try { - readCsvLines(url) -} catch (e: Exception) { - logger.e(e) { "Failed to download required CSV $url; aborting" } - throw IllegalStateException("Failed to download required CSV: $url", e) +private fun forDbReleaseArchiveUrl(logger: Logger): String { + val body = OptimizedHttpClient.fetchJson(FOR_DB_RELEASE_API, FOR_DB_USER_AGENT, logger) + val archiveNamePattern = Regex.escape(FOR_DB_ARCHIVE_NAME) + return Regex(""""browser_download_url"\s*:\s*"([^"]*/$archiveNamePattern)"""") + .find(body) + ?.groupValues + ?.get(1) + ?: throw IllegalStateException("No $FOR_DB_ARCHIVE_NAME asset found in fordb-latest release") } -private fun readCsvLines(url: String): List { - val conn = URI(url).toURL().openConnection().apply { - connectTimeout = 10_000 - readTimeout = 30_000 +private fun readForDbZip(stream: InputStream): Map> { + val csvs = mutableMapOf>() + 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 + } } - val lines = conn.getInputStream().use { it.reader(StandardCharsets.UTF_8).readLines() } - return if (lines.isEmpty()) lines else listOf(lines.first().removePrefix("\uFEFF")) + lines.drop(1) + 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 index 79516b8f..04110015 100644 --- 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 @@ -27,7 +27,7 @@ import kotlin.system.exitProcess * Env alternatives: * SEFORIM_DB */ -private val GENERATIONS_URL = forDbUrl(FOR_DB_CSV_FILES.getValue("generations")) +private val GENERATIONS_FILE = FOR_DB_CSV_FILES.getValue("generations") fun main(args: Array) { Logger.setMinSeverity(Severity.Info) @@ -46,7 +46,7 @@ fun main(args: Array) { logger.i { "Seeding generations in $dbPath" } - val rows = parseGenerations(downloadRequiredCsv(GENERATIONS_URL, logger), logger) + val rows = parseGenerations(downloadRequiredForDbCsv(GENERATIONS_FILE, logger), logger) try { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { conn -> From 52646170cb4fa322639c1061c6179bd704d3a770 Mon Sep 17 00:00:00 2001 From: y-ploni Date: Sun, 7 Jun 2026 10:29:19 +0300 Subject: [PATCH 20/29] FIX --- .../RenameCategoriesPostProcess.kt | 278 +++++++++++------- .../SeedGenerationsPostProcess.kt | 76 +++-- 2 files changed, 208 insertions(+), 146 deletions(-) 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 a83bd086..d4f8de00 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 @@ -3,6 +3,10 @@ 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 @@ -20,9 +24,9 @@ import kotlin.system.exitProcess * Otzaria, so naming is unified before additional books are added. * * Rules are downloaded from otzaria-library/ForDB/ on GitHub (UTF-8): - * - ืชื™ืงื™ื•ืช.csv `old,new` โ€” category renames (exact match, prefix fallback) - * - ืกืคืจื™ื.csv `old,new` โ€” book title renames (exact match) - * - Moving files.csv `name,sourcePath,destPath` (simple CSV; embedded newlines in quoted fields are not supported) + * - 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 @@ -32,13 +36,12 @@ import kotlin.system.exitProcess * 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 destination category path to already exist; missing - * destinations are skipped with a warning (no auto-creation). + * 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 - * Moving files.csv must therefore reference the POST-rename category names. - * If a move references a pre-rename destPath, resolveCategoryPath returns null - * and the move is silently skipped with a "destination not found" warning. + * 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 @@ -51,10 +54,10 @@ private const val FOR_DB_RELEASE_API = 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 "ืชื™ืงื™ื•ืช.csv", - "bookRenames" to "ืกืคืจื™ื.csv", - "bookMoves" to "Moving files.csv", - "generations" to "ืกื“ืจ ื”ื“ื•ืจื•ืช.csv", + "categoryRenames" to "category_renames.csv", + "bookRenames" to "book_renames.csv", + "bookMoves" to "book_moves.csv", + "generations" to "generations.csv", ) fun main(args: Array) { @@ -75,11 +78,14 @@ fun main(args: Array) { logger.i { "Renaming/merging categories in $dbPath" } // Rules downloaded from otzaria-library/ForDB/ at startup. - // ืชื™ืงื™ื•ืช.csv and ืกืคืจื™ื.csv have no header; Moving files.csv has one. - val categoryRenames: List> = - parsePairs(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("categoryRenames"), logger)) + // category_renames.csv and book_renames.csv have no header; book_moves.csv has one. + val categoryRenames: List = + parseCategoryRenames(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("categoryRenames"), logger)) val bookRenames: List> = - parsePairs(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("bookRenames"), logger)) + parsePairs( + downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("bookRenames"), logger), + FOR_DB_CSV_FILES.getValue("bookRenames") + ) val bookMoves: List = parseBookMoves(downloadRequiredForDbCsv(FOR_DB_CSV_FILES.getValue("bookMoves"), logger), logger) @@ -89,12 +95,14 @@ fun main(args: Array) { var totalRenamed = 0 var totalMerged = 0 - val categoryResult = runSection("Category renames", categoryRenames, logger) { (oldName, newName) -> - val result = renameOrMergeCategory(conn, oldName, newName, logger) + val categoryResult = 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 -> logger.w { "Category rename: '$oldName' not found; skipping" } + is RenameResult.NotFound -> logger.i { + "Category rename: '${rule.oldName}' not found; no rows changed" + } } result.rows() } @@ -103,9 +111,7 @@ fun main(args: Array) { renameBookTitle(conn, oldTitle, newTitle, logger) } - val moveResult = runSection("Book moves", bookMoves, logger) { move -> - if (applyBookMove(conn, move, logger)) 1 else 0 - } + val moveResult = runSection("Book moves", bookMoves, logger) { move -> applyBookMove(conn, move, logger) } conn.commit() logger.i { @@ -121,49 +127,105 @@ fun main(args: Array) { } } -/** `old,new` rows โ€” skips blanks and malformed lines. */ -internal fun parsePairs(lines: List): List> = lines.mapNotNull { line -> - val f = parseCsvLine(line).map { it.trim() } - if (f.size >= 2 && f[0].isNotEmpty() && f[1].isNotEmpty()) f[0] to f[1] else null +/** `old,new` rows โ€” ignores blank rows and fails on malformed non-blank rows. */ +internal fun parsePairs(lines: List, sourceName: String = "pairs CSV"): List> = + 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): List = + 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() + +internal fun parseRequiredCsvRows(lines: List, sourceName: String, minFields: Int): List> = + lines.mapIndexedNotNull { index, line -> + val fields = parseForDbCsvLine(line).map { it.trim() } + if (fields.all { it.isEmpty() }) return@mapIndexedNotNull null + require(fields.size >= minFields && fields.take(minFields).all { it.isNotEmpty() }) { + "$sourceName row ${index + 1} is malformed: $line" + } + fields + } + +internal fun parseForDbCsvLine(line: String): List { + val result = mutableListOf() + 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. - * If the first line doesn't look like a header, treats it as data and logs a - * warning so a header-less upload doesn't silently lose row 0. + * Missing or malformed headers fail the task so release data issues are not + * hidden. */ private fun parseBookMoves(lines: List, logger: Logger): List { val firstLower = lines.firstOrNull()?.lowercase() val isHeader = firstLower != null && "source path" in firstLower && "destination path" in firstLower - val body = if (isHeader) { - lines.drop(1) - } else { - if (lines.isNotEmpty()) logger.w { "Moving files.csv: no header detected; treating first row as data" } - lines - } - return body.mapNotNull { line -> - val f = parseCsvLine(line).map { it.trim() } - if (f.size >= 3 && f[0].isNotEmpty() && f[2].isNotEmpty()) BookMove(f[0], f[1], f[2]) else null + 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 data class SectionResult(val applied: Int, val failures: Int) private fun runSection(name: String, items: List, logger: Logger, apply: (T) -> Int): SectionResult { var applied = 0 - var failures = 0 for (item in items) { - try { - applied += apply(item) - } catch (e: Exception) { - failures++ - logger.w(e) { "$name failed for '$item'; skipping" } - } + applied += apply(item) } - logger.i { "$name: applied=$applied failures=$failures" } - return SectionResult(applied, failures) + logger.i { "$name: applied=$applied failures=0" } + return SectionResult(applied, failures = 0) } private sealed class RenameResult { @@ -185,12 +247,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 (exact match, falling back to prefix) - val sourceCats = findSourceCategories(conn, oldName) + val oldName = rule.oldName + val newName = rule.newName + val sourceCats = findSourceCategories(conn, rule) if (sourceCats.isEmpty()) { return RenameResult.NotFound @@ -270,12 +332,10 @@ private fun deleteCategory(conn: Connection, categoryId: Long) { } /** - * Exact match first; only if nothing matches exactly, fall back to prefix match. - * This preserves the safer "literal" interpretation for the common case while - * still allowing rules like "ืจืืฉื•ื ื™ื ืขืœ" to sweep "ืจืืฉื•ื ื™ื ืขืœ ื”ืชืœืžื•ื“", - * "ืจืืฉื•ื ื™ื ืขืœ ื”ืžืฉื ื”", etc. + * Category prefix rules are intentionally limited to [EXPLICIT_CATEGORY_PREFIX_RULES]. + * All other rows are exact-match only. */ -private fun findSourceCategories(conn: Connection, pattern: String): List> { +private fun findSourceCategories(conn: Connection, rule: CategoryRename): List> { fun query(sql: String, param: String): List> { val rows = mutableListOf>() conn.prepareStatement(sql).use { stmt -> @@ -290,43 +350,61 @@ private fun findSourceCategories(conn: Connection, pattern: String): List + 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) + } + } } -// Upstream rename CSV uses bare-acronym keys (e.g. ืจื“ืง) while Sefaria stores -// the punctuated form (ืจื“ืดืง / ืจื“"ืง). Compare with quotes/geresh stripped on -// both sides; the new title is still written exactly as the CSV provides it. -internal fun stripTitlePunct(s: String): String = - s.replace("\"", "").replace("ืด", "").replace("'", "").replace("ืณ", "") - -internal const val STRIP_TITLE_PUNCT_SQL = - "REPLACE(REPLACE(REPLACE(REPLACE(title, '\"', ''), 'ืด', ''), '''', ''), 'ืณ', '')" - private fun renameBookTitle(conn: Connection, oldTitle: String, newTitle: String, logger: Logger): Int { - val sql = "UPDATE book SET title = ? WHERE $STRIP_TITLE_PUNCT_SQL = ?" - val n = conn.prepareStatement(sql).use { stmt -> + 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.setString(2, stripTitlePunct(oldTitle)) + stmt.setLong(2, ids.single()) stmt.executeUpdate() } - if (n > 0) logger.i { "Renamed book '$oldTitle' -> '$newTitle' ($n rows)" } - else logger.w { "Book rename: '$oldTitle' not found; skipping" } + logger.i { "Renamed book '$oldTitle' -> '$newTitle' ($n rows)" } return n } +private fun findBookIdsByTitle(conn: Connection, title: String): List = + 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 destPath against the existing category tree and updates the matching - * book's categoryId. Missing destinations are skipped (no auto-creation). - * Disambiguates by source path when multiple books share a title; an empty - * sourcePath is only safe when the title is globally unique. + * 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): Boolean { +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>() // (bookId, categoryId) conn.prepareStatement("SELECT id, categoryId FROM book WHERE title = ?").use { stmt -> stmt.setString(1, move.name) @@ -334,27 +412,17 @@ private fun applyBookMove(conn: Connection, move: BookMove, logger: Logger): Boo while (rs.next()) candidates.add(rs.getLong(1) to rs.getLong(2)) } } - if (candidates.isEmpty()) { - logger.w { "Book move: '${move.name}' not found; skipping" } - return false - } + require(candidates.isNotEmpty()) { "Book move: '${move.name}' not found" } - val sourceCatId = resolveCategoryPath(conn, move.sourcePath) - val bookId = when { - candidates.size == 1 -> candidates.single().first - sourceCatId != null -> candidates.firstOrNull { it.second == sourceCatId }?.first - else -> null - } - if (bookId == null) { - logger.w { "Book move: '${move.name}' has ${candidates.size} candidates; source '${move.sourcePath}' did not disambiguate; skipping" } - return false + candidates.singleOrNull { it.second == destCatId }?.let { (bookId, _) -> + logger.i { "Book move '${move.name}' already applied (id=$bookId, dest=${move.destPath})" } + return 0 } - - val destCatId = resolveCategoryPath(conn, move.destPath) - if (destCatId == null) { - logger.w { "Book move: destination '${move.destPath}' not found; skipping" } - return false + 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) @@ -362,7 +430,7 @@ private fun applyBookMove(conn: Connection, move: BookMove, logger: Logger): Boo stmt.executeUpdate() } logger.i { "Moved book '${move.name}' (id=$bookId) -> '${move.destPath}' (catId=$destCatId)" } - return true + return 1 } /** Walks `a/b/c` from category roots; returns null on the first missing segment. */ @@ -377,8 +445,7 @@ private fun resolveCategoryPath(conn: Connection, path: String): Long? { } internal fun downloadRequiredForDbCsv(fileName: String, logger: Logger): List { - val lines = forDbReleaseCsvs(logger).getValue(fileName) - return if (lines.isEmpty()) lines else listOf(lines.first().removePrefix("\uFEFF")) + lines.drop(1) + return forDbReleaseCsvs(logger).getValue(fileName) } private var cachedForDbCsvs: Map>? = null @@ -412,12 +479,11 @@ private fun forDbReleaseCsvs(logger: Logger): Map> { private fun forDbReleaseArchiveUrl(logger: Logger): String { val body = OptimizedHttpClient.fetchJson(FOR_DB_RELEASE_API, FOR_DB_USER_AGENT, logger) - val archiveNamePattern = Regex.escape(FOR_DB_ARCHIVE_NAME) - return Regex(""""browser_download_url"\s*:\s*"([^"]*/$archiveNamePattern)"""") - .find(body) - ?.groupValues - ?.get(1) - ?: throw IllegalStateException("No $FOR_DB_ARCHIVE_NAME asset found in fordb-latest release") + 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> { 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 index 04110015..8c1f740b 100644 --- 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 @@ -10,7 +10,7 @@ import kotlin.system.exitProcess /** * Seeds the `generation` table and links books to their generation, driven by - * otzaria-library/ForDB/ืกื“ืจ ื”ื“ื•ืจื•ืช.csv (`ืฉื ืกืคืจ,ืงื‘ื•ืฆืช ื“ื•ืจ` with header). + * 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 @@ -53,13 +53,12 @@ fun main(args: Array) { conn.autoCommit = false val result = try { - val res = applyGenerations(conn, rows, logger) - conn.commit() - res + applyGenerations(conn, rows, logger).also { + conn.commit() + } } catch (e: Exception) { runCatching { conn.rollback() }.onFailure { logger.w(it) { "Rollback failed" } } - logger.w(e) { "Generation seeding failed; skipping section" } - GenerationApplyResult(0, 0, rows.size) + throw e } logger.i { @@ -68,24 +67,30 @@ fun main(args: Array) { } } } catch (e: Exception) { - logger.e(e) { "Failed to open or commit DB; aborting" } + logger.e(e) { "Failed to seed generations; aborting" } exitProcess(1) } } /** - * `ืฉื ืกืคืจ,ืงื‘ื•ืฆืช ื“ื•ืจ` rows. Drops the header row if its first field contains - * "ืฉื ืกืคืจ"; otherwise treats every row as data (with a warning) so a - * header-less upload doesn't silently lose row 0. + * `ืฉื ืกืคืจ,ืงื‘ื•ืฆืช ื“ื•ืจ` 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, logger: Logger): List> { - if (lines.isEmpty()) return emptyList() - val isHeader = "ืฉื ืกืคืจ" in lines.first() - val body = if (isHeader) lines.drop(1) else { - logger.w { "ืกื“ืจ ื”ื“ื•ืจื•ืช.csv: no header detected; treating first row as data" } - lines + 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)" } } - return parsePairs(body) + 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( @@ -96,10 +101,9 @@ private data class GenerationApplyResult( /** * Seeds the `generation` table with distinct names and links each book to its - * generation via `book_generation`. Loads books once into in-memory maps so - * the three-tier matching (exact / punct-strip / TRIM) is O(1) per CSV row - * instead of full table scans on REPLACE/TRIM expressions. INSERT OR IGNORE - * keeps re-runs idempotent. + * 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, @@ -130,18 +134,15 @@ private fun applyGenerations( } } val exactMap = books.groupBy({ it.second }, { it.first }) - val strippedMap = books.groupBy({ stripTitlePunct(it.second) }, { it.first }) - val trimmedMap = books.groupBy({ it.second.trim() }, { it.first }) var linksCreated = 0 - var unmatched = 0 + val unmatchedTitles = mutableListOf() 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, strippedMap, trimmedMap, bookTitle, logger) + val bookId = findBookIdForGeneration(exactMap, bookTitle, logger) if (bookId == null) { - unmatched++ - logger.d { "Generation link: book '$bookTitle' not found; skipping" } + unmatchedTitles += bookTitle continue } linkStmt.setLong(1, bookId) @@ -149,32 +150,27 @@ private fun applyGenerations( linksCreated += linkStmt.executeUpdate() } } - return GenerationApplyResult(generationsCreated, linksCreated, unmatched) + require(unmatchedTitles.isEmpty()) { + "Generation CSV has ${unmatchedTitles.size} unmatched book title(s): " + + unmatchedTitles.take(20).joinToString() + } + return GenerationApplyResult(generationsCreated, linksCreated, 0) } -// Three-tier fallback: exact, then TRIM (for DB titles imported with stray -// leading/trailing whitespace from upstream sources), then punctuation-strip -// (for CSVs that use the bare form like ืจื“ืง vs ืจื“ืดืง). TRIM before punct-strip -// so a title like "ืจื“ืง " resolves to the unique "ืจื“ืง" entry rather than -// hitting the broader punct-strip tier which matches both "ืจื“ืง" and "ืจื“ืดืง". -// `book.title` is not UNIQUE in the schema, so any tier can return >1 โ€” log -// and skip rather than arbitrarily picking one. +// `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>, - strippedMap: Map>, - trimmedMap: Map>, title: String, logger: Logger, ): Long? { exactMap[title]?.let { return pickOne(it, title, "exact", logger) } - trimmedMap[title.trim()]?.let { return pickOne(it, title, "TRIM", logger) } - strippedMap[stripTitlePunct(title)]?.let { return pickOne(it, title, "punct-strip", logger) } return null } private fun pickOne(matches: List, title: String, tier: String, logger: Logger): Long? = if (matches.size == 1) matches.single() else { - logger.w { "Generation link: '$title' has multiple $tier matches; skipping" } - null + logger.e { "Generation link: '$title' has multiple $tier matches" } + error("Generation link '$title' has ${matches.size} $tier matches") } From 54d0722e1c9ecd250d8ef3fcfc18a5f7887d1768 Mon Sep 17 00:00:00 2001 From: batsheva Date: Sun, 7 Jun 2026 20:42:12 +0300 Subject: [PATCH 21/29] refactor(sefariasqlite): drop dead failure counters from rename post-process runSection aborts on first error, so SectionResult.failures was always 0. Return the applied count directly and drop failures= from the log lines. --- .../RenameCategoriesPostProcess.kt | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) 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 d4f8de00..5b1e8b97 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 @@ -95,7 +95,7 @@ fun main(args: Array) { var totalRenamed = 0 var totalMerged = 0 - val categoryResult = runSection("Category renames", categoryRenames, logger) { rule -> + runSection("Category renames", categoryRenames, logger) { rule -> val result = renameOrMergeCategory(conn, rule, logger) when (result) { is RenameResult.Renamed -> totalRenamed += result.count @@ -107,18 +107,16 @@ fun main(args: Array) { result.rows() } - val bookRenameResult = runSection("Book renames", bookRenames, logger) { (oldTitle, newTitle) -> + val booksRenamed = runSection("Book renames", bookRenames, logger) { (oldTitle, newTitle) -> renameBookTitle(conn, oldTitle, newTitle, logger) } - val moveResult = runSection("Book moves", bookMoves, logger) { move -> applyBookMove(conn, move, logger) } + val booksMoved = runSection("Book moves", bookMoves, logger) { move -> applyBookMove(conn, move, logger) } conn.commit() logger.i { - "Post-process done: categories renamed=$totalRenamed merged=$totalMerged " + - "(failures=${categoryResult.failures}); books renamed=${bookRenameResult.applied} " + - "(failures=${bookRenameResult.failures}); books moved=${moveResult.applied} " + - "(failures=${moveResult.failures})" + "Post-process done: categories renamed=$totalRenamed merged=$totalMerged; " + + "books renamed=$booksRenamed; books moved=$booksMoved" } } } catch (e: Exception) { @@ -217,15 +215,13 @@ private fun parseBookMoves(lines: List, logger: Logger): List .also { logger.i { "Loaded ${it.size} book move rule(s)" } } } -private data class SectionResult(val applied: Int, val failures: Int) - -private fun runSection(name: String, items: List, logger: Logger, apply: (T) -> Int): SectionResult { +private fun runSection(name: String, items: List, logger: Logger, apply: (T) -> Int): Int { var applied = 0 for (item in items) { applied += apply(item) } - logger.i { "$name: applied=$applied failures=0" } - return SectionResult(applied, failures = 0) + logger.i { "$name: applied=$applied" } + return applied } private sealed class RenameResult { From 66d8b0aaf6ab8b977c0d68741d8d7d472e08a622 Mon Sep 17 00:00:00 2001 From: YOSEFTT Date: Wed, 10 Jun 2026 15:31:11 +0300 Subject: [PATCH 22/29] =?UTF-8?q?=D7=A2=D7=93=D7=9B=D7=95=D7=9F=20=D7=94?= =?UTF-8?q?=D7=A8=D7=A9=D7=99=D7=9E=D7=94=20=D7=94=D7=A9=D7=97=D7=95=D7=A8?= =?UTF-8?q?=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sefariasqlite/src/jvmMain/resources/books_blacklist.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt b/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt index 64c7973e..9205e822 100644 --- a/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt +++ b/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt @@ -203,8 +203,10 @@ ื”ืฉื•ืชืคื•ืช ื”ื’ื“ื•ืœื”; ื”ื“ืช, ื”ืžื“ืข ื•ื”ื—ื™ืคื•ืฉ ืื—ืจ ืžืฉืžืขื•ืช ืื ื”ื‘ื ื™ื ืฉืžื—ื” ืžืฉืคื˜ื™ ืขื•ื–ื™ืืœ + ื ื•ืกื— ื”ื›ืชื•ื‘ื” ืกื™ื“ื•ืจ ืืฉื›ื ื– + ืกืคืจ ื”ื™ื•ื‘ืœื™ื ืืžื•ื ื” ื‘ืขืชื™ื“ ื”ื‘ื™ืช ืฉืื ื• ื‘ื•ื ื™ื ื‘ื™ื—ื“; ื‘ื ื™ื™ื” ืžื—ื“ืฉ ืฉืœ ื”ื—ื‘ืจื” From 72e01ed90213972c18b8ce5b5768494ef9595f86 Mon Sep 17 00:00:00 2001 From: ypl <7353755@gmail.com> Date: Fri, 12 Jun 2026 19:13:58 +0300 Subject: [PATCH 23/29] Removing trim from csv files --- .../RenameCategoriesPostProcess.kt | 8 ++-- .../sefariasqlite/ForDbCsvParsingTest.kt | 47 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 generator/sefariasqlite/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/sefariasqlite/ForDbCsvParsingTest.kt 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 5b1e8b97..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 @@ -153,11 +153,13 @@ private fun parseCategoryRenames(lines: List): List = 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, sourceName: String, minFields: Int): List> = lines.mapIndexedNotNull { index, line -> - val fields = parseForDbCsvLine(line).map { it.trim() } - if (fields.all { it.isEmpty() }) return@mapIndexedNotNull null - require(fields.size >= minFields && fields.take(minFields).all { it.isNotEmpty() }) { + 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 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 { + parseRequiredCsvRows(listOf("ื‘ืจืืฉื™ืช, "), sourceName = "test.csv", minFields = 2) + } + } +} From 8b4ae4c22c7f49fba0917e46f78e24b99c1af009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=99=D7=95=D7=A1=D7=A3?= Date: Wed, 17 Jun 2026 01:09:25 +0300 Subject: [PATCH 24/29] Update books blacklist with new entries --- .../sefariasqlite/src/jvmMain/resources/books_blacklist.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt b/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt index 9205e822..11fd128e 100644 --- a/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt +++ b/generator/sefariasqlite/src/jvmMain/resources/books_blacklist.txt @@ -206,7 +206,6 @@ ื ื•ืกื— ื”ื›ืชื•ื‘ื” ืกื™ื“ื•ืจ ืืฉื›ื ื– - ืกืคืจ ื”ื™ื•ื‘ืœื™ื ืืžื•ื ื” ื‘ืขืชื™ื“ ื”ื‘ื™ืช ืฉืื ื• ื‘ื•ื ื™ื ื‘ื™ื—ื“; ื‘ื ื™ื™ื” ืžื—ื“ืฉ ืฉืœ ื”ื—ื‘ืจื” From 4caf6b9ae5d0e033d1e5640def1bd812db3b3db1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Jun 2026 20:35:22 +0000 Subject: [PATCH 25/29] Update release manifest [skip ci] --- release-manifest.json | 123 +++++++++--------------------------------- 1 file changed, 26 insertions(+), 97 deletions(-) diff --git a/release-manifest.json b/release-manifest.json index d992f7c9..08ff2d5b 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,116 +1,45 @@ { - "generatedAt": "2026-06-18T12:30:17Z", + "generatedAt": "2026-06-22T20:35:22Z", "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": 10286, + "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" } ] } From 774955e71d36aa107e4f29115be6931c6ec37843 Mon Sep 17 00:00:00 2001 From: ypl <7353755@gmail.com> Date: Tue, 23 Jun 2026 01:06:58 +0300 Subject: [PATCH 26/29] Delta Fix: Migration to Missing Tables --- build.gradle.kts | 26 +++--- generator/common/build.gradle.kts | 2 +- .../common/patch/PatchDbProducer.kt | 60 ++++++++++++- .../common/patch/PatchSchemaEvolutionTest.kt | 90 +++++++++++++++++++ 4 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 generator/common/src/jvmTest/kotlin/io/github/kdroidfilter/seforimlibrary/common/patch/PatchSchemaEvolutionTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index a07ff783..3344fbea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -115,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 @@ -122,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 " + @@ -147,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/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("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/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> { + val out = ArrayList>() + 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 { + val out = ArrayList() + 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/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) + } +} From 751458671601a2f61efcbc15f538da63d89b807a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Jun 2026 22:07:15 +0000 Subject: [PATCH 27/29] Update release manifest [skip ci] --- release-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release-manifest.json b/release-manifest.json index 08ff2d5b..482219d0 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-22T20:35:22Z", + "generatedAt": "2026-06-22T22:07:15Z", "latest": { "assets": [ { @@ -21,7 +21,7 @@ "contentType": "application/zstd", "createdAt": "2026-04-16T10:26:18Z", "digest": "sha256:f99f8e2041abe7273c512a6cdd1d57ab356f2439285954b6da113760bdfe2dc9", - "downloadCount": 10286, + "downloadCount": 10303, "id": "RA_kwDOQ6kTXM4XtG1v", "label": "", "name": "seforim.db.zst", From 1db022dd0280565531c9b023358af15e472deada Mon Sep 17 00:00:00 2001 From: batsheva Date: Fri, 5 Jun 2026 06:22:07 +0300 Subject: [PATCH 28/29] fix(sefariasqlite): store long description in heDesc, real short desc in heShortDesc The importer filled book.heShortDesc with Sefaria's long heDesc text, and Sefaria's real heShortDesc was never read. Add a nullable heDesc column to book and split the reader: extractDescription keeps the long text (now -> heDesc), extractShortDescription reads the real one-line summary (-> heShortDesc). (added to the schema, Book model, insert queries, and the DB->model mapping) --- .../seforimlibrary/core/models/Book.kt | 2 ++ .../dao/extensions/ModelExtensions.kt | 1 + .../dao/repository/SeforimRepository.kt | 2 ++ .../seforimlibrary/db/BookQueries.sq | 8 +++--- .../seforimlibrary/db/Database.sq | 2 ++ .../sefariasqlite/SefariaBookPayloadReader.kt | 13 ++++++++++ .../sefariasqlite/SefariaDirectImporter.kt | 3 ++- .../sefariasqlite/SefariaImportModels.kt | 3 +++ .../sefariasqlite/SefariaAltTocBuilderTest.kt | 1 + .../SefariaBookPayloadReaderTest.kt | 25 +++++++++++++++++++ 10 files changed, 55 insertions(+), 5 deletions(-) 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/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/SeforimRepository.kt b/dao/src/commonMain/kotlin/io/github/kdroidfilter/seforimlibrary/dao/repository/SeforimRepository.kt index ae94ca89..a89c4f3d 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 @@ -906,6 +906,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 +965,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(), 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 8a3eb027..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 @@ -138,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, 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 a152ec88..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) @@ -144,6 +145,7 @@ internal class SefariaBookPayloadReader( 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/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", From b6c6aed4ffac3b4b1fdca9283899f7dea011f795 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:26:54 +0000 Subject: [PATCH 29/29] Update release manifest [skip ci] --- release-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-manifest.json b/release-manifest.json index 482219d0..8af65093 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-22T22:07:15Z", + "generatedAt": "2026-06-22T22:26:54Z", "latest": { "assets": [ {