Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.github.kdroidfilter.seforimlibrary.core.models

import kotlinx.serialization.Serializable

/**
* A commentator's placement in a book's default-commentator list.
*
* Positions may be non-contiguous: an absent commentator leaves its slot empty rather than
* shifting the rest up, so every commentator keeps a stable column in fixed-slot page layouts.
* Honoring [position] is opt-in — a consumer that reads the list in order and ignores it is
* unaffected; it simply renders the commentators consecutively, with no gap.
*
* @property commentatorBookId The book id of the commentator
* @property position The position the commentator occupies in the list
*/
@Serializable
data class DefaultCommentatorPosition(
val commentatorBookId: Long,
val position: Int
)
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.github.kdroidfilter.seforimlibrary.core.models.Author
import io.github.kdroidfilter.seforimlibrary.core.models.Book
import io.github.kdroidfilter.seforimlibrary.core.models.Category
import io.github.kdroidfilter.seforimlibrary.core.models.ConnectionType
import io.github.kdroidfilter.seforimlibrary.core.models.DefaultCommentatorPosition
import io.github.kdroidfilter.seforimlibrary.core.models.Line
import io.github.kdroidfilter.seforimlibrary.core.models.LineAltTocMapping
import io.github.kdroidfilter.seforimlibrary.core.models.LineTocMapping
Expand Down Expand Up @@ -2572,22 +2573,32 @@ class SeforimRepository(databasePath: String, private val driver: SqlDriver) : L
// --- Default commentators ---

/**
* Returns the list of commentator book IDs configured as defaults for the given book.
* Returns the default commentators for the given book, ordered by position.
* Positions may be non-contiguous (a consumer may leave intentional gaps), so
* they are preserved for safe read-modify-write.
*/
suspend fun getDefaultCommentatorIdsForBook(bookId: Long): List<Long> = withContext(Dispatchers.IO) {
database.defaultCommentatorQueriesQueries.selectByBookId(bookId).executeAsList()
}
suspend fun getDefaultCommentatorsForBook(bookId: Long): List<DefaultCommentatorPosition> =
withContext(Dispatchers.IO) {
database.defaultCommentatorQueriesQueries.selectByBookIdWithPosition(bookId)
.executeAsList()
.map { DefaultCommentatorPosition(it.commentatorBookId, it.position.toInt()) }
}

/**
* Replaces the default commentators list for a given book with the provided ordered IDs.
* Replaces the default commentator list for a given book. Positions may be
* non-contiguous (gaps are intentional, e.g. via the "-" sentinel in
* default_commentators.json).
*/
suspend fun setDefaultCommentatorsForBook(bookId: Long, commentatorBookIds: List<Long>) = withContext(Dispatchers.IO) {
suspend fun setDefaultCommentatorsForBook(
bookId: Long,
commentators: List<DefaultCommentatorPosition>
) = withContext(Dispatchers.IO) {
database.defaultCommentatorQueriesQueries.deleteByBookId(bookId)
commentatorBookIds.forEachIndexed { index, commentatorBookId ->
commentators.forEach { (commentatorBookId, position) ->
database.defaultCommentatorQueriesQueries.insert(
bookId = bookId,
commentatorBookId = commentatorBookId,
position = index.toLong()
position = position.toLong()
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
-- Queries for default commentators per book

selectByBookId:
SELECT commentatorBookId
selectByBookIdWithPosition:
SELECT commentatorBookId, position
FROM default_commentator
WHERE bookId = ?
ORDER BY position;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import co.touchlab.kermit.Severity
import io.github.kdroidfilter.seforimlibrary.common.ids.IdAllocatorBindings
import io.github.kdroidfilter.seforimlibrary.common.ids.InMemoryIdAllocator
import io.github.kdroidfilter.seforimlibrary.core.models.ConnectionType
import io.github.kdroidfilter.seforimlibrary.core.models.DefaultCommentatorPosition
import io.github.kdroidfilter.seforimlibrary.core.models.Link
import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository
import kotlinx.coroutines.runBlocking
Expand Down Expand Up @@ -643,7 +644,10 @@ private suspend fun setHearotAsDefaultCommentators(
val hearotBook = hearotByTractate[tractateName]

if (hearotBook != null) {
repository.setDefaultCommentatorsForBook(havroutaBook.id, listOf(hearotBook.id))
repository.setDefaultCommentatorsForBook(
havroutaBook.id,
listOf(DefaultCommentatorPosition(hearotBook.id, 0))
)
count++
logger.d { "Set ${hearotBook.title} as default for ${havroutaBook.title}" }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ internal class SefariaAltTocBuilder(
}
}.filterNot { it.isBlank() }.toSet()

// Alt-struct refs sometimes use a non-primary title spelling listed in
// the index titleVariants (e.g. "Messilat Yesharim" vs the primary
// "Mesillat Yesharim"). An unrecognized book title fails canonical
// matching and falls onto the bare-ordinal tail fallback, which then
// collides with a same-numbered Introduction segment. Rewrite a known
// alias prefix back to the primary title so the lookup hits the chapter.
val canonicalEnTitle = canonicalCitation(payload.enTitle)
val canonicalHeTitle = canonicalCitation(payload.heTitle)
val titleAliasesCanonical = buildSet {
payload.titleAliasKeys.forEach { add(canonicalCitation(it)) }
addAll(bookAliasKeys)
}.filterNot { it.isBlank() || it == canonicalEnTitle || it == canonicalHeTitle }
.sortedByDescending { it.length }

fun normalizeCitationBookTitle(raw: String): String {
val canon = canonicalCitation(raw)
val primary =
if (canon.any { it in 'א'..'ת' }) canonicalHeTitle else canonicalEnTitle
for (alias in titleAliasesCanonical) {
if (canon == alias) return primary
if (canon.startsWith("$alias ")) return primary + canon.substring(alias.length)
}
return raw
}

val canonicalToLine: Map<String, Pair<Long?, Int?>> = buildMap {
refsForBook.forEach { entry ->
val lineIdx = entry.lineIndex - 1
Expand Down Expand Up @@ -124,6 +149,7 @@ internal class SefariaAltTocBuilder(
allowTailFallback: Boolean = true
): Pair<Long?, Int?> {
if (citation.isNullOrBlank()) return null to null
val normalizedCitation = normalizeCitationBookTitle(citation)

fun expandedCandidates(base: String): List<String> {
if (base.isBlank() || maxColonDepth <= 0) return emptyList()
Expand Down Expand Up @@ -224,10 +250,10 @@ internal class SefariaAltTocBuilder(
return null
}

lookup(citation)?.let { return it }
lookup(normalizedCitation)?.let { return it }

if (isChapterOrSimanLevel) {
val canonical = canonicalCitation(citation)
val canonical = canonicalCitation(normalizedCitation)
val base = canonical.substringBefore('-').trim()
if (!base.contains(':')) {
val withColon = "$base:1"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,44 @@
package io.github.kdroidfilter.seforimlibrary.sefariasqlite

import co.touchlab.kermit.Logger
import io.github.kdroidfilter.seforimlibrary.core.models.DefaultCommentatorPosition
import io.github.kdroidfilter.seforimlibrary.dao.repository.SeforimRepository
import kotlinx.serialization.json.Json

// ערך ב-default_commentators.json שמסמן "slot ריק" — משאיר פער (gap) ברצף
// ה-position בלי מפרש. הפרשנות של הפער נתונה לתוכנה הצורכת. ראה applyDefaultCommentators.
private const val EMPTY_SLOT_SENTINEL = "-"

/**
* Loads default commentators configuration from bundled JSON.
*
* @return a map keyed by normalized base-book title → ordered list of normalized commentator titles.
* @return a map keyed by normalized base-book title → ordered list of slots. Each slot is a
* normalized commentator title, or `null` for an intentional gap in the position sequence ("-").
*/
internal fun loadDefaultCommentatorsConfig(
classLoader: ClassLoader?,
json: Json,
logger: Logger
): Map<String, List<String>> = try {
): Map<String, List<String?>> = try {
val stream = classLoader?.getResourceAsStream("default_commentators.json") ?: return emptyMap()
val jsonText = stream.bufferedReader(Charsets.UTF_8).use { it.readText() }
val entries = json.decodeFromString<List<DefaultCommentatorsEntry>>(jsonText)
entries.mapNotNull { entry ->
val bookKey = normalizeTitleKey(entry.book)
if (bookKey.isNullOrBlank()) return@mapNotNull null
val commentatorKeys = entry.commentators
.mapNotNull { normalizeTitleKey(it) }
.filter { it.isNotBlank() }
if (commentatorKeys.isEmpty()) return@mapNotNull null
bookKey to commentatorKeys
val slots: List<String?> = buildList {
entry.commentators.forEach { raw ->
if (raw.trim() == EMPTY_SLOT_SENTINEL) {
add(null) // slot ריק מכוון
} else {
val key = normalizeTitleKey(raw)
if (!key.isNullOrBlank()) add(key)
// מחרוזת ריקה/רווחים: לא slot — מושמטת
}
}
}
if (slots.none { it != null }) return@mapNotNull null
bookKey to slots
}.toMap()
} catch (e: Exception) {
logger.w(e) { "Unable to read default_commentators.json, continuing without default commentators" }
Expand Down Expand Up @@ -61,7 +75,7 @@ internal fun loadDefaultTargumConfig(
internal suspend fun applyDefaultCommentators(
repository: SeforimRepository,
logger: Logger,
defaultsByBookKey: Map<String, List<String>>,
defaultsByBookKey: Map<String, List<String?>>,
normalizedTitleToBookId: Map<String, Long>
) {
if (defaultsByBookKey.isEmpty()) return
Expand All @@ -73,26 +87,50 @@ internal suspend fun applyDefaultCommentators(

var totalRows = 0

defaultsByBookKey.forEach { (bookKey, commentatorKeys) ->
defaultsByBookKey.forEach { (bookKey, slots) ->
val baseBookId = normalizedTitleToBookId[bookKey] ?: return@forEach

val uniqueCommentatorIds = LinkedHashSet<Long>()
commentatorKeys.forEach { commentatorKey ->
val commentatorBookId = normalizedTitleToBookId[commentatorKey]
if (commentatorBookId != null && commentatorBookId != baseBookId) {
uniqueCommentatorIds += commentatorBookId
}
}

if (uniqueCommentatorIds.isNotEmpty()) {
repository.setDefaultCommentatorsForBook(baseBookId, uniqueCommentatorIds.toList())
totalRows += uniqueCommentatorIds.size
val positioned =
resolvePositionedCommentators(slots, baseBookId, normalizedTitleToBookId)
if (positioned.isNotEmpty()) {
repository.setDefaultCommentatorsForBook(baseBookId, positioned)
totalRows += positioned.size
}
}

logger.i { "Inserted $totalRows default commentator rows" }
}

/**
* Resolves slots to [DefaultCommentatorPosition] entries.
*
* A null slot ("-") advances the position without emitting an entry, leaving a gap in the
* position sequence. A missing or duplicate commentator is packed (position not advanced) —
* backward-compatible with contiguous defaults.
*/
internal fun resolvePositionedCommentators(
slots: List<String?>,
baseBookId: Long,
normalizedTitleToBookId: Map<String, Long>
): List<DefaultCommentatorPosition> {
val positioned = mutableListOf<DefaultCommentatorPosition>()
val seen = mutableSetOf<Long>()
var position = 0
slots.forEach { key ->
if (key == null) {
position++
return@forEach
}
val commentatorBookId = normalizedTitleToBookId[key]
if (commentatorBookId != null && commentatorBookId != baseBookId &&
seen.add(commentatorBookId)
) {
positioned += DefaultCommentatorPosition(commentatorBookId, position)
position++
}
}
return positioned
}

internal suspend fun applyDefaultTargumim(
repository: SeforimRepository,
logger: Logger,
Expand Down
Loading