From 7681590992fa8636f12c13548d31850ba0b17d5c Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 25 May 2026 20:37:32 +0300 Subject: [PATCH] contents for reading --- .../sergeych/toread/BookPlatform.android.kt | 18 ++ .../net/sergeych/toread/LibraryPlatform.kt | 1 + .../net/sergeych/toread/Localization.kt | 4 + .../net/sergeych/toread/ReaderContent.kt | 33 ++- .../net/sergeych/toread/ReaderScreen.kt | 221 ++++++++++++++++-- .../toread/ReadAloudContentPlanTest.kt | 28 +++ .../net/sergeych/toread/BookPlatform.jvm.kt | 18 ++ 7 files changed, 301 insertions(+), 22 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt index bdcf9c7..55383b2 100644 --- a/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/net/sergeych/toread/BookPlatform.android.kt @@ -832,16 +832,34 @@ private fun ReadingPosition.toFormatHintsJson(): String = buildString { append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""") readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") } + if (backStack.isNotEmpty()) { + append(""","backStack":[""") + append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() }) + append("]") + } append("}") } private fun String.toReadingPosition(): ReadingPosition? { + val position = toReadingPositionObject() ?: return null + val backStackJson = Regex(""""backStack"\s*:\s*\[(.*)]""").find(this)?.groupValues?.getOrNull(1) + val backStack = backStackJson + ?.let { ReadingPositionObjectRegex.findAll(it).mapNotNull { match -> match.value.toReadingPositionObject() }.toList() } + .orEmpty() + .take(MaxStoredReadingBackStack) + return position.copy(backStack = backStack) +} + +private fun String.toReadingPositionObject(): ReadingPosition? { val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null } +private val ReadingPositionObjectRegex = Regex("""[{][^{}]*[}]""") +private const val MaxStoredReadingBackStack = 10 + private fun String.toSearchPrefixes(): List = SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList() diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt index 09ed7fd..63de677 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/LibraryPlatform.kt @@ -62,6 +62,7 @@ data class ReadingPosition( val itemIndex: Int, val scrollOffset: Int, val readAloudSentenceIndex: Int? = null, + val backStack: List = emptyList(), ) data class ReaderFontSettings( diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt index a8c7b48..972c4dc 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/Localization.kt @@ -133,6 +133,8 @@ internal open class AppStrings { open val noImage = "No image" open val readerTheme = "Theme" + open val tableOfContents = "Contents" + open val backToLastPosition = "Back" open val readAloud = "Read aloud" open val readerMenu = "Book reader menu" open val info = "Info..." @@ -337,6 +339,8 @@ internal object RussianStrings : AppStrings() { override val noImage = "Нет изображения" override val readerTheme = "Тема" + override val tableOfContents = "Содержание" + override val backToLastPosition = "Назад" override val readAloud = "Читать вслух" override val readerMenu = "Меню чтения" override val info = "Информация..." diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt index 02742be..61c6de0 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderContent.kt @@ -1463,6 +1463,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit + lateinit var addSections: (List, Int) -> Unit + val tocEntries = mutableListOf() addPoemSentences = { itemIndex, poem -> poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) } @@ -1497,18 +1499,20 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) } } - fun addSection(section: Fb2Section, readerDepth: Int) { + fun addSection(section: Fb2Section, readerDepth: Int, fallbackTitle: String) { + val title = section.title?.ifBlank { null } ?: fallbackTitle + val itemIndex = elements.size + tocEntries += ReaderTocEntry(title, itemIndex, readerDepth) if (section.title.isNullOrBlank()) { elements += ReaderElement.SectionSeparator } else { - val itemIndex = elements.size - elements += ReaderElement.SectionTitle(section.title!!, readerDepth) + elements += ReaderElement.SectionTitle(title, readerDepth) sentences += ReadAloudSentence( index = sentences.size, itemIndex = itemIndex, start = 0, - endExclusive = section.title!!.length, - text = section.title!!, + endExclusive = title.length, + text = title, pauseBeforeMillis = HeadingPauseBeforeMillis + pendingPauseBeforeMillis, pauseAfterMillis = HeadingPauseAfterMillis, ) @@ -1548,7 +1552,13 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { } } val childDepth = if (blocks.isEmpty()) readerDepth else readerDepth + 1 - section.sections.forEach { addSection(it, childDepth) } + addSections(section.sections, childDepth) + } + + addSections = { sections, readerDepth -> + sections.forEachIndexed { index, section -> + addSection(section, readerDepth, strings.sectionFallback(index)) + } } elements += ReaderElement.Cover @@ -1558,15 +1568,16 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan { addEpigraphSentences(itemIndex, epigraph) elements += ReaderElement.Epigraph(epigraph, 0) } - book.sections.forEach { addSection(it, 0) } + addSections(book.sections, 0) elements += ReaderElement.FixedSpacer(22) - return ReaderContentPlan(elements, sentences) + return ReaderContentPlan(elements, sentences, tocEntries) } internal data class ReaderContentPlan( val elements: List, val sentences: List, + val tocEntries: List, ) { fun sentenceIndexAtOrAfterItem(itemIndex: Int): Int = sentences.firstOrNull { it.itemIndex >= itemIndex }?.index @@ -1579,6 +1590,12 @@ internal data class ReaderContentPlan( ?: sentenceIndexAtOrAfterItem(position.itemIndex) } +internal data class ReaderTocEntry( + val title: String, + val itemIndex: Int, + val depth: Int, +) + internal sealed interface ReaderElement { data object Cover : ReaderElement data class FixedSpacer(val heightDp: Int) : ReaderElement diff --git a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt index 0dbd1c9..8612ce0 100644 --- a/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt +++ b/composeApp/src/commonMain/kotlin/net/sergeych/toread/ReaderScreen.kt @@ -1,21 +1,26 @@ package net.sergeych.toread import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.BookmarkAdd import androidx.compose.material.icons.filled.BookmarkRemove @@ -109,6 +114,9 @@ internal fun BookView( var readAloudPanelVisible by remember(fileId) { mutableStateOf(false) } var readAloudSettingsVisible by remember(fileId) { mutableStateOf(false) } var readerSettingsPanelVisible by remember(fileId) { mutableStateOf(false) } + var tableOfContentsVisible by remember(fileId) { mutableStateOf(false) } + var tableOfContentsBackStack by remember(fileId) { mutableStateOf>(emptyList()) } + var tableOfContentsBackPosition by remember(fileId) { mutableStateOf(null) } var readerFontSettings by remember { mutableStateOf(defaultReaderFontSettings()) } var readAloudResumeSentenceIndex by remember(fileId) { mutableStateOf(null) } var userScrollGeneration by remember(fileId) { mutableStateOf(0) } @@ -147,6 +155,38 @@ internal fun BookView( } } + fun currentReadingPosition(backStack: List = tableOfContentsBackStack): ReadingPosition = + ReadingPosition( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset, + readAloudResumeSentenceIndex, + backStack, + ) + + fun openPosition(position: ReadingPosition, backStack: List) { + scope.launch { + val target = position.copy(backStack = backStack) + readAloudResumeSentenceIndex = target.readAloudSentenceIndex + ?: contentPlan.sentenceIndexAtOrAfterItem(target.itemIndex) + listState.animateScrollToItem(target.itemIndex, target.scrollOffset) + saveLibraryReadingPosition(fileId, target) + } + } + + fun openTableOfContents() { + scope.launch { + val current = currentReadingPosition() + val savedStack = loadLibraryReadingPosition(fileId)?.backStack.orEmpty() + val nextStack = (listOf(current) + savedStack) + .withoutAdjacentReadingDuplicates() + .take(MaxTableOfContentsBackStack) + tableOfContentsBackStack = nextStack + tableOfContentsBackPosition = nextStack.backPositionFrom(current) + saveLibraryReadingPosition(fileId, current.copy(backStack = nextStack)) + tableOfContentsVisible = true + } + } + fun setReadingStatus(status: BookReadingStatus, successMessage: String) { scope.launch { if (markLibraryReadingStatus(fileId, status)) { @@ -157,7 +197,7 @@ internal fun BookView( if (status == BookReadingStatus.NOT_INTERESTED) { saveLibraryReadingPosition( fileId, - ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset), + currentReadingPosition(), ) saveActiveReadingFileId(null) onBack() @@ -192,6 +232,8 @@ internal fun BookView( LaunchedEffect(fileId) { loadLibraryReadingPosition(fileId)?.let { position -> + tableOfContentsBackStack = position.backStack.take(MaxTableOfContentsBackStack) + tableOfContentsBackPosition = tableOfContentsBackStack.backPositionFrom(position) readAloudResumeSentenceIndex = position.readAloudSentenceIndex listState.scrollToItem(position.itemIndex, position.scrollOffset) } @@ -201,11 +243,7 @@ internal fun BookView( LaunchedEffect(fileId, listState, readAloudState.active, userScrollGeneration) { if (readAloudState.active) return@LaunchedEffect snapshotFlow { - ReadingPosition( - listState.firstVisibleItemIndex, - listState.firstVisibleItemScrollOffset, - readAloudResumeSentenceIndex, - ) + currentReadingPosition() } .filter { restored && userScrollGeneration > 0 } .distinctUntilChanged() @@ -238,7 +276,7 @@ internal fun BookView( val sentence = activeReadAloudSentence ?: return@LaunchedEffect readAloudResumeSentenceIndex = sentence.index val itemIndex = sentence.itemIndex - saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0, sentence.index)) + saveLibraryReadingPosition(fileId, ReadingPosition(itemIndex, 0, sentence.index, tableOfContentsBackStack)) if (listState.firstVisibleItemIndex != itemIndex || listState.firstVisibleItemScrollOffset != 0) { listState.animateScrollToItem(itemIndex, 0) } @@ -255,11 +293,12 @@ internal fun BookView( scope.launch { saveLibraryReadingPosition( fileId, - ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset), + currentReadingPosition(), ) onBookInfo() } }, + onTableOfContents = ::openTableOfContents, onMarkAsRead = { setReadingStatus(BookReadingStatus.READ, strings.markedAsRead()) }, @@ -302,11 +341,7 @@ internal fun BookView( }, showReadAloudAction = showReadAloudAction, onReadAloud = { - val position = ReadingPosition( - listState.firstVisibleItemIndex, - listState.firstVisibleItemScrollOffset, - readAloudResumeSentenceIndex, - ) + val position = currentReadingPosition() val startIndex = contentPlan.resumeSentenceIndex(position) ReadAloudPlatform.prepare(fileId, book.title, contentPlan.sentences, startIndex) readAloudPanelVisible = true @@ -332,7 +367,7 @@ internal fun BookView( scope.launch { saveLibraryReadingPosition( fileId, - ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset), + currentReadingPosition(), ) saveActiveReadingFileId(null) onBack() @@ -431,6 +466,35 @@ internal fun BookView( onNoteOpen = ::openReaderLink, ) } + + if (tableOfContentsVisible) { + val currentTocItemIndex = contentPlan.tocEntries + .lastOrNull { it.itemIndex <= listState.firstVisibleItemIndex } + ?.itemIndex + TableOfContentsDialog( + contentPlan = contentPlan, + currentItemIndex = currentTocItemIndex, + backPosition = tableOfContentsBackPosition, + onOpenPosition = { position -> + tableOfContentsVisible = false + val targetIndex = tableOfContentsBackStack.indexOfFirst { it.sameReadingPlace(position) } + val remainingStack = if (targetIndex >= 0) { + tableOfContentsBackStack.drop(targetIndex + 1) + } else { + tableOfContentsBackStack + } + tableOfContentsBackStack = remainingStack + tableOfContentsBackPosition = remainingStack.backPositionFrom(position) + openPosition(position, remainingStack) + }, + onOpenEntry = { entry -> + tableOfContentsVisible = false + val sentenceIndex = contentPlan.sentenceIndexAtOrAfterItem(entry.itemIndex) + openPosition(ReadingPosition(entry.itemIndex, 0, sentenceIndex), tableOfContentsBackStack) + }, + onDismiss = { tableOfContentsVisible = false }, + ) + } } @Composable @@ -438,6 +502,7 @@ private fun CompactReaderTopBar( title: String, onThemeToggle: () -> Unit, onBookInfo: () -> Unit, + onTableOfContents: () -> Unit, onMarkAsRead: () -> Unit, onMarkToRead: () -> Unit, onNotInterested: () -> Unit, @@ -475,6 +540,9 @@ private fun CompactReaderTopBar( IconButton(onClick = onThemeToggle) { Icon(Icons.Filled.Palette, contentDescription = strings.readerTheme) } + IconButton(onClick = onTableOfContents) { + Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = strings.tableOfContents) + } if (showReadAloudAction) { IconButton(onClick = onReadAloud) { Icon(Icons.AutoMirrored.Filled.VolumeUp, contentDescription = strings.readAloud) @@ -616,6 +684,131 @@ private fun CompactReaderTopBar( } } +@Composable +private fun TableOfContentsDialog( + contentPlan: ReaderContentPlan, + currentItemIndex: Int?, + backPosition: ReadingPosition?, + onOpenPosition: (ReadingPosition) -> Unit, + onOpenEntry: (ReaderTocEntry) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(strings.tableOfContents) }, + text = { + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp), + ) { + item(key = "back") { + TableOfContentsBackRow( + enabled = backPosition != null, + onClick = { backPosition?.let(onOpenPosition) }, + ) + HorizontalDivider() + } + items(contentPlan.tocEntries, key = { it.itemIndex }) { entry -> + TableOfContentsEntryRow( + entry = entry, + selected = entry.itemIndex == currentItemIndex, + onClick = { onOpenEntry(entry) }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(strings.done) + } + }, + ) +} + +@Composable +private fun TableOfContentsBackRow(enabled: Boolean, onClick: () -> Unit) { + val contentColor = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(20.dp), + ) + Text( + text = strings.backToLastPosition, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = contentColor, + modifier = Modifier.padding(start = 12.dp), + ) + } +} + +@Composable +private fun TableOfContentsEntryRow( + entry: ReaderTocEntry, + selected: Boolean, + onClick: () -> Unit, +) { + val rowModifier = Modifier + .fillMaxWidth() + .then( + if (selected) { + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + } else { + Modifier + }, + ) + .clickable(onClick = onClick) + .padding( + start = (12 + entry.depth * 16).dp, + top = 10.dp, + end = 12.dp, + bottom = 10.dp, + ) + Text( + text = entry.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = rowModifier, + ) +} + +private fun List.withoutAdjacentReadingDuplicates(): List = + fold(emptyList()) { acc, position -> + if (acc.lastOrNull()?.sameReadingPlace(position) == true) acc else acc + position.withoutReadingBackStack() + } + +private fun ReadingPosition.sameReadingPlace(other: ReadingPosition): Boolean = + itemIndex == other.itemIndex && + scrollOffset == other.scrollOffset && + readAloudSentenceIndex == other.readAloudSentenceIndex + +private fun ReadingPosition.withoutReadingBackStack(): ReadingPosition = + copy(backStack = emptyList()) + +private fun List.backPositionFrom(current: ReadingPosition): ReadingPosition? = + firstOrNull { !it.sameReadingPlace(current) } + +private const val MaxTableOfContentsBackStack = 10 + @Composable private fun ReaderFontSettingsPanel( settings: ReaderFontSettings, diff --git a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt index 763714d..1213f2b 100644 --- a/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt +++ b/composeApp/src/commonTest/kotlin/net/sergeych/toread/ReadAloudContentPlanTest.kt @@ -305,6 +305,34 @@ class ReadAloudContentPlanTest { ) } + @Test + fun contentPlanIncludesClickableTableOfContentsEntries() { + val plan = buildReaderContentPlan( + Fb2Book( + title = "Book", + sections = listOf( + Fb2Section( + title = "Part", + sections = listOf( + Fb2Section( + title = "Chapter", + blocks = listOf(paragraph("Chapter text.")), + ), + ), + ), + Fb2Section(blocks = listOf(paragraph("Untitled text."))), + ), + ), + ) + + assertEquals(listOf("Part", "Chapter", "Section 2"), plan.tocEntries.map { it.title }) + assertEquals(listOf(0, 0, 0), plan.tocEntries.map { it.depth }) + assertEquals( + plan.elements.filterIsInstance().first(), + plan.elements[plan.tocEntries.first().itemIndex], + ) + } + private fun paragraph(text: String): Fb2Block.Paragraph = Fb2Block.Paragraph(text(text)) diff --git a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt index ffddcdd..fef57ce 100644 --- a/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/net/sergeych/toread/BookPlatform.jvm.kt @@ -665,16 +665,34 @@ private fun ReadingPosition.toFormatHintsJson(): String = buildString { append("""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset""") readAloudSentenceIndex?.let { append(""","readAloudSentenceIndex":$it""") } + if (backStack.isNotEmpty()) { + append(""","backStack":[""") + append(backStack.take(MaxStoredReadingBackStack).joinToString(",") { it.toFormatHintsJson() }) + append("]") + } append("}") } private fun String.toReadingPosition(): ReadingPosition? { + val position = toReadingPositionObject() ?: return null + val backStackJson = Regex(""""backStack"\s*:\s*\[(.*)]""").find(this)?.groupValues?.getOrNull(1) + val backStack = backStackJson + ?.let { ReadingPositionObjectRegex.findAll(it).mapNotNull { match -> match.value.toReadingPositionObject() }.toList() } + .orEmpty() + .take(MaxStoredReadingBackStack) + return position.copy(backStack = backStack) +} + +private fun String.toReadingPositionObject(): ReadingPosition? { val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() val sentenceIndex = Regex(""""readAloudSentenceIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull() return if (index != null && offset != null) ReadingPosition(index, offset, sentenceIndex) else null } +private val ReadingPositionObjectRegex = Regex("""[{][^{}]*[}]""") +private const val MaxStoredReadingBackStack = 10 + private fun String.toSearchPrefixes(): List = SearchPrefixRegex.findAll(lowercase()).map { it.value }.distinct().toList()