Compare commits

...

2 Commits

14 changed files with 832 additions and 112 deletions

View File

@ -377,9 +377,15 @@ actual suspend fun viewLibraryBookFile(fileId: String): Boolean = false
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras()
val sourceFileName = file.originalFilename ?: file.storageUri?.substringAfterLast('/')
val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras(
sourceFileName = sourceFileName,
sourceFilePath = file.storageUri,
)
val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
BookInfoExtras(
sourceFileName = sourceFileName,
sourceFilePath = file.storageUri,
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
BookmarkInfo(
title = it.title,

View File

@ -92,6 +92,16 @@ internal fun BookInfoScreen(
}
}
}
item {
InfoSection(strings.sourceFile) {
if (extras == null) {
Text(strings.loading, style = MaterialTheme.typography.bodyMedium)
} else {
DetailLine(strings.fileName, extras?.sourceFileName?.ifBlank { null } ?: strings.notSpecified)
DetailLine(strings.filePath, extras?.sourceFilePath?.ifBlank { null } ?: strings.notSpecified)
}
}
}
item {
InfoSection(strings.lastReadingPosition) {
val position = extras?.lastReadingPosition

View File

@ -64,6 +64,8 @@ data class ReadingPosition(
)
data class BookInfoExtras(
val sourceFileName: String? = null,
val sourceFilePath: String? = null,
val bookmarks: List<BookmarkInfo> = emptyList(),
val notes: List<NoteInfo> = emptyList(),
val lastReadingPosition: ReadingPosition? = null,

View File

@ -97,9 +97,7 @@ internal fun LibraryScreen(
var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) }
var items by remember(state.items) { mutableStateOf(state.items) }
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached by remember(state.items) { mutableStateOf(false) }
var loadingLibrary by remember(state.items) { mutableStateOf(false) }
var recentlyAddedItems by remember(state.items) { mutableStateOf<List<LibraryItem>>(emptyList()) }
var wasScanning by remember { mutableStateOf(false) }
var settingsMenuOpen by remember { mutableStateOf(false) }
@ -121,38 +119,26 @@ internal fun LibraryScreen(
val recentlyAdded = recentlyAddedItems.filterNot { it.fileId in hiddenFileIds }
val visibleItems = selectedFilter.apply(sourceItems, recentlyAdded, searchActive)
.withoutDuplicateFileIds()
val canLoadMore = !searchActive && selectedFilter.usesPagedLibrary && !endReached
suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return
loadingPage = true
suspend fun loadLibrary() {
if (loadingLibrary) return
loadingLibrary = true
val previousItems = items
if (reset) {
nextOffset = 0
endReached = false
}
val offset = if (reset) 0 else nextOffset
try {
val limit = if (reset) maxOf(LibraryPageSize, previousItems.size) else LibraryPageSize
val page = loadLibraryItemsPage(limit, offset)
if (reset) {
if (page != previousItems) {
items = page
val loadedItems = loadLibraryItems()
if (loadedItems != previousItems) {
items = loadedItems
}
val visibleFileIds = page.mapTo(mutableSetOf()) { it.fileId }
val visibleFileIds = loadedItems.mapTo(mutableSetOf()) { it.fileId }
recentlyAddedItems.mapTo(visibleFileIds) { it.fileId }
searchResults.mapTo(visibleFileIds) { it.fileId }
coverCache.keys.toList().forEach { fileId ->
if (fileId !in visibleFileIds) coverCache.remove(fileId)
}
} else {
items = items.appendNewLibraryItems(page)
}
nextOffset = offset + page.size
endReached = page.size < limit
} catch (t: Throwable) {
message = t.message ?: strings.couldNotLoadLibrary
endReached = true
} finally {
loadingPage = false
loadingLibrary = false
}
}
@ -169,7 +155,7 @@ internal fun LibraryScreen(
searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false
} else {
loadPage(reset = true)
loadLibrary()
loadRecentlyAdded()
}
}
@ -191,7 +177,7 @@ internal fun LibraryScreen(
searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false
} else {
loadPage(reset = true)
loadLibrary()
loadRecentlyAdded()
}
} else {
@ -215,7 +201,7 @@ internal fun LibraryScreen(
searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false
} else {
loadPage(reset = true)
loadLibrary()
loadRecentlyAdded()
}
} else {
@ -255,8 +241,8 @@ internal fun LibraryScreen(
}
LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) {
loadPage(reset = true)
if (items.isEmpty()) {
loadLibrary()
loadRecentlyAdded()
}
}
@ -293,9 +279,9 @@ internal fun LibraryScreen(
}
}
LaunchedEffect(searchActive, loadingPage, endReached, libraryItems, recentlyAdded) {
val libraryDataLoaded = endReached || libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty()
if (!filterChosenByUser && !searchActive && !loadingPage && libraryDataLoaded) {
LaunchedEffect(searchActive, loadingLibrary, libraryItems, recentlyAdded) {
val libraryDataLoaded = libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty()
if (!filterChosenByUser && !searchActive && !loadingLibrary && libraryDataLoaded) {
selectedFilter = defaultLibraryFilter(libraryItems, recentlyAdded)
}
}
@ -328,12 +314,12 @@ internal fun LibraryScreen(
wasScanning = true
while (true) {
delay(2_000)
loadPage(reset = true)
loadLibrary()
loadRecentlyAdded()
}
} else if (wasScanning) {
wasScanning = false
loadPage(reset = true)
loadLibrary()
loadRecentlyAdded()
}
}
@ -486,11 +472,11 @@ internal fun LibraryScreen(
.background(readerBackground()),
) {
val wide = maxWidth >= 800.dp
if (visibleItems.isEmpty() && (loadingPage || searching)) {
if (visibleItems.isEmpty() && (loadingLibrary || searching)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (visibleItems.isEmpty() && !canLoadMore) {
} else if (visibleItems.isEmpty()) {
if (searchActive) {
EmptySearchPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else {
@ -520,7 +506,7 @@ internal fun LibraryScreen(
readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem)
coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId)
}
if (item.readingStatus == BookReadingStatus.NEW &&
if (item.readingStatus.shouldBecomeReadingOnOpen() &&
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
) {
val readingItem = loadLibraryItem(item.fileId)
@ -613,7 +599,6 @@ internal fun LibraryScreen(
val previousRecentlyAddedItems = recentlyAddedItems
val previousCover = coverCache[item.fileId]
val hadCover = coverCache.containsKey(item.fileId)
val previousNextOffset = nextOffset
onDeleteRequested(
LibraryDeleteRequest(item.fileId, item.title),
{
@ -622,7 +607,6 @@ internal fun LibraryScreen(
searchResults = searchResults.filterNot { it.fileId == item.fileId }
recentlyAddedItems = recentlyAddedItems.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId)
nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
},
{
items = previousItems
@ -631,7 +615,6 @@ internal fun LibraryScreen(
if (hadCover) {
coverCache[item.fileId] = previousCover
}
nextOffset = previousNextOffset
},
)
},
@ -649,20 +632,6 @@ internal fun LibraryScreen(
}
libraryRows(visibleItems)
if (canLoadMore) {
item(key = "load-more") {
LaunchedEffect(nextOffset, items.size) {
if (!loadingPage) loadPage()
}
Box(
modifier = Modifier.fillMaxWidth().padding(18.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
}
}
}
}
}
activeScan?.let { progress ->
@ -1002,9 +971,9 @@ private data class LibraryItemActions(
val onDelete: () -> Unit,
)
private enum class LibraryFilter(val usesPagedLibrary: Boolean = true) {
private enum class LibraryFilter {
ReadingNow,
RecentlyAdded(usesPagedLibrary = false),
RecentlyAdded,
MyLibrary,
ToRead,
Favorites,
@ -1193,15 +1162,12 @@ private fun Long.formatBytes(): String =
private fun List<LibraryItem>.replaceLibraryItem(item: LibraryItem): List<LibraryItem> =
map { current -> if (current.fileId == item.fileId) item else current }
internal fun List<LibraryItem>.appendNewLibraryItems(page: List<LibraryItem>): List<LibraryItem> {
if (isEmpty()) return page.withoutDuplicateFileIds()
val fileIds = mapTo(mutableSetOf()) { it.fileId }
return this + page.filter { fileIds.add(it.fileId) }
}
internal fun List<LibraryItem>.withoutDuplicateFileIds(): List<LibraryItem> =
distinctBy { it.fileId }
internal fun BookReadingStatus.shouldBecomeReadingOnOpen(): Boolean =
this == BookReadingStatus.NEW || this == BookReadingStatus.NOT_INTERESTED
private fun Key.isEnterKey(): Boolean = this == Key.Enter || this == Key.NumPadEnter
private fun LibraryScanProgress.toCatalogScanMessage(): String {
@ -1214,7 +1180,6 @@ private fun LibraryScanProgress.toCatalogScanMessage(): String {
return strings.scannedProgress(scannedFiles, total, percent)
}
private const val LibraryPageSize: Int = 50
private const val SearchResultLimit: Int = 100
private const val RecentlyAddedLimit: Int = 50
private const val RecentlyAddedWindowMillis: Long = 30L * 60L * 60L * 1000L

View File

@ -117,6 +117,9 @@ internal open class AppStrings {
open val words = "Words"
open val sections = "Sections"
open val images = "Images"
open val sourceFile = "Source file"
open val fileName = "File name"
open val filePath = "Path"
open val lastReadingPosition = "Last Reading Position"
open val noSavedPosition = "No saved position"
open val listItem = "List item"
@ -243,7 +246,7 @@ internal object RussianStrings : AppStrings() {
override val couldNotOpenBook = "Не удалось открыть книгу."
override val couldNotUpdateBook = "Не удалось обновить книгу."
override val bookFileNotAvailable = "Файл книги недоступен."
override val scanFailed = "Сканирование не удалось."
override val scanFailed = "Импорт не удался."
override val searchFailed = "Поиск не удался."
override val libraryRescanFailed = "Повторное сканирование библиотеки не удалось."
override val rescanningLibrary = "Пересканируем библиотеку..."
@ -264,7 +267,7 @@ internal object RussianStrings : AppStrings() {
override val closeSearch = "Закрыть поиск"
override val clearSearch = "Очистить поиск"
override val noMatches = "Ничего не найдено"
override val scanFolderOrChooseFilter = "Просканируйте папку или выберите другой фильтр библиотеки."
override val scanFolderOrChooseFilter = "Импортируйте папку или выберите другой фильтр библиотеки."
override val favorite = "Избранное"
override val unknownAuthor = "Автор неизвестен"
override val noMetadata = "Нет метаданных"
@ -287,7 +290,7 @@ internal object RussianStrings : AppStrings() {
override val filterToRead = "К чтению"
override val filterRead = "Прочитанные"
override val scan = "Сканирование"
override val scan = "Импорт"
override val rootFolder = "Корневая папка"
override val choose = "Выбрать"
override val logPrefix = "Лог"
@ -309,6 +312,9 @@ internal object RussianStrings : AppStrings() {
override val words = "Слова"
override val sections = "Разделы"
override val images = "Иллюстрации"
override val sourceFile = "Исходный файл"
override val fileName = "Имя файла"
override val filePath = "Путь"
override val lastReadingPosition = "Последняя позиция чтения"
override val noSavedPosition = "Позиция не сохранена"
override val listItem = "Элемент списка"
@ -378,9 +384,9 @@ internal object RussianStrings : AppStrings() {
}
override fun importedSkippedFailed(imported: Int, skipped: Int, failed: Int): String =
"Импортировано: $imported, пропущено: $skipped, ошибок: $failed"
override fun scannedBooks(scanned: Int): String = "Просканировано книг: $scanned"
override fun scannedBooks(scanned: Int): String = "Импортировано книг: $scanned"
override fun scannedProgress(scanned: Int, total: Int, percent: Int): String =
"Просканировано $scanned из $total, готово $percent%"
"Импорт: $scanned из $total, готово $percent%"
override fun noBooksIn(filterLabel: String): String = "Нет книг: ${filterLabel.lowercase()}"
override fun bookMenuFor(title: String): String = "Меню книги: $title"
override fun markedAsRead(title: String?): String =

View File

@ -79,7 +79,12 @@ import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Cite
import net.sergeych.toread.fb2.Fb2Epigraph
import net.sergeych.toread.fb2.Fb2EpigraphBlock
import net.sergeych.toread.fb2.Fb2ImageRef
import net.sergeych.toread.fb2.Fb2Poem
import net.sergeych.toread.fb2.Fb2PoemBlock
import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.fb2.Fb2Text
import net.sergeych.toread.fb2.Fb2TextSpan
@ -210,6 +215,27 @@ internal fun ContinuousBookReader(
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() },
)
is ReaderElement.Epigraph -> ReaderEpigraph(
epigraph = element.epigraph,
language = book.language,
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = element.depth,
)
is ReaderElement.Cite -> ReaderCite(
cite = element.cite,
language = book.language,
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = element.depth,
)
is ReaderElement.Poem -> ReaderPoem(
poem = element.poem,
language = book.language,
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = element.depth,
)
}
}
}
@ -467,6 +493,13 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
style = readerParagraphTextStyle(book.language),
textAlign = TextAlign.Unspecified,
)
is Fb2Block.Poem -> ReaderPoem(
poem = block.poem,
language = book.language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
)
is Fb2Block.Subtitle -> ReaderText(
text = block.content,
language = book.language,
@ -475,6 +508,20 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
)
is Fb2Block.Epigraph -> ReaderEpigraph(
epigraph = block.epigraph,
language = book.language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
)
is Fb2Block.Cite -> ReaderCite(
cite = block.cite,
language = book.language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
)
}
}
item { Spacer(Modifier.height(22.dp)) }
@ -543,6 +590,212 @@ private fun ReaderText(
)
}
@Composable
private fun ReaderPoem(
poem: Fb2Poem,
language: String?,
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
depth: Int,
modifier: Modifier = Modifier,
) {
val segments = remember(poem) { poem.readerSegments() }
Column(
modifier = modifier
.fillMaxWidth()
.padding(start = (depth * 8).dp, top = 8.dp, bottom = 8.dp),
) {
poem.epigraphs.forEachIndexed { index, epigraph ->
ReaderEpigraph(
epigraph = epigraph,
language = language,
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = 0,
modifier = Modifier.padding(top = if (index == 0) 0.dp else 8.dp, bottom = 8.dp),
)
}
segments.forEach { segment ->
if (segment.gapBeforeDp > 0) {
Spacer(Modifier.height(segment.gapBeforeDp.dp))
}
ReaderText(
text = segment.text,
language = language,
hyphenation = hyphenation,
style = poemTextStyle(segment.kind, language),
highlightedRange = highlightedRange?.forSegment(segment),
textAlign = when (segment.kind) {
ReaderPoemSegmentKind.Title,
ReaderPoemSegmentKind.Subtitle -> TextAlign.Center
ReaderPoemSegmentKind.TextAuthor,
ReaderPoemSegmentKind.Date -> TextAlign.End
ReaderPoemSegmentKind.Verse -> TextAlign.Start
},
modifier = when (segment.kind) {
ReaderPoemSegmentKind.Verse -> Modifier.padding(start = 22.dp)
else -> Modifier
},
)
}
}
}
@Composable
private fun ReaderEpigraph(
epigraph: Fb2Epigraph,
language: String?,
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
depth: Int,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(start = (depth * 8 + 26).dp, end = 18.dp, top = 8.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
epigraph.blocks.forEach { block ->
ReaderEpigraphBlock(
block = block,
language = language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
)
}
epigraph.textAuthors.forEach { author ->
ReaderText(
text = author,
language = language,
hyphenation = hyphenation,
style = epigraphAuthorTextStyle(language),
highlightedRange = null,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
)
}
}
}
@Composable
private fun ReaderCite(
cite: Fb2Cite,
language: String?,
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
depth: Int,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(start = (depth * 8 + 22).dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
cite.blocks.forEach { block ->
ReaderEpigraphBlock(
block = block,
language = language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
)
}
cite.textAuthors.forEach { author ->
ReaderText(
text = author,
language = language,
hyphenation = hyphenation,
style = epigraphAuthorTextStyle(language),
highlightedRange = null,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
)
}
}
}
@Composable
private fun ReaderEpigraphBlock(
block: Fb2EpigraphBlock,
language: String?,
hyphenation: HyphenationRegistry,
highlightedRange: ReaderSentenceRange?,
depth: Int,
) {
when (block) {
Fb2EpigraphBlock.EmptyLine -> Spacer(Modifier.height(12.dp))
is Fb2EpigraphBlock.Paragraph -> ReaderText(
text = block.content,
language = language,
hyphenation = hyphenation,
style = epigraphTextStyle(language),
highlightedRange = highlightedRange,
textAlign = TextAlign.Start,
)
is Fb2EpigraphBlock.Subtitle -> ReaderText(
text = block.content,
language = language,
hyphenation = hyphenation,
style = epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold),
highlightedRange = highlightedRange,
textAlign = TextAlign.Center,
)
is Fb2EpigraphBlock.Poem -> ReaderPoem(
poem = block.poem,
language = language,
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = depth,
)
is Fb2EpigraphBlock.Cite -> ReaderCite(
cite = block.cite,
language = language,
hyphenation = hyphenation,
highlightedRange = highlightedRange,
depth = depth,
)
}
}
@Composable
private fun poemTextStyle(kind: ReaderPoemSegmentKind, language: String?): TextStyle =
when (kind) {
ReaderPoemSegmentKind.Title -> MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold,
lineHeight = 26.sp,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
)
ReaderPoemSegmentKind.Subtitle -> MaterialTheme.typography.bodyLarge.copy(
fontStyle = FontStyle.Italic,
lineHeight = 24.sp,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
)
ReaderPoemSegmentKind.Verse -> readerParagraphTextStyle(language).copy(
lineHeight = 24.sp,
)
ReaderPoemSegmentKind.TextAuthor,
ReaderPoemSegmentKind.Date -> MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
lineHeight = 22.sp,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
)
}
@Composable
private fun epigraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
lineHeight = 22.sp,
localeList = language?.takeIf(String::isNotBlank)?.let { LocaleList(Locale(it)) },
)
@Composable
private fun epigraphAuthorTextStyle(language: String?): TextStyle =
epigraphTextStyle(language).copy(fontWeight = FontWeight.SemiBold)
@Composable
private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy(
@ -766,6 +1019,7 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
fun addTextSentences(
itemIndex: Int,
text: Fb2Text,
offset: Int = 0,
pauseBeforeMillis: Long = 0,
pauseAfterMillis: Long = 0,
) {
@ -784,8 +1038,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
sentences += ReadAloudSentence(
index = sentences.size,
itemIndex = itemIndex,
start = range.start,
endExclusive = range.endExclusive,
start = offset + range.start,
endExclusive = offset + range.endExclusive,
text = sentenceText,
spokenText = spokenText,
pauseBeforeMillis = effectivePauseBefore,
@ -795,6 +1049,43 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
}
}
lateinit var addPoemSentences: (Int, Fb2Poem) -> Unit
lateinit var addCiteSentences: (Int, Fb2Cite) -> Unit
lateinit var addEpigraphSentences: (Int, Fb2Epigraph) -> Unit
addPoemSentences = { itemIndex, poem ->
poem.epigraphs.forEach { epigraph -> addEpigraphSentences(itemIndex, epigraph) }
poem.readerSegments().forEach { segment ->
addTextSentences(itemIndex, segment.text, offset = segment.startOffset)
}
}
addCiteSentences = { itemIndex, cite ->
cite.blocks.forEach { block ->
when (block) {
Fb2EpigraphBlock.EmptyLine -> Unit
is Fb2EpigraphBlock.Paragraph -> addTextSentences(itemIndex, block.content)
is Fb2EpigraphBlock.Subtitle -> addTextSentences(itemIndex, block.content)
is Fb2EpigraphBlock.Poem -> addPoemSentences(itemIndex, block.poem)
is Fb2EpigraphBlock.Cite -> addCiteSentences(itemIndex, block.cite)
}
}
cite.textAuthors.forEach { addTextSentences(itemIndex, it) }
}
addEpigraphSentences = { itemIndex, epigraph ->
epigraph.blocks.forEach { block ->
when (block) {
Fb2EpigraphBlock.EmptyLine -> Unit
is Fb2EpigraphBlock.Paragraph -> addTextSentences(itemIndex, block.content)
is Fb2EpigraphBlock.Subtitle -> addTextSentences(itemIndex, block.content)
is Fb2EpigraphBlock.Poem -> addPoemSentences(itemIndex, block.poem)
is Fb2EpigraphBlock.Cite -> addCiteSentences(itemIndex, block.cite)
}
}
epigraph.textAuthors.forEach { addTextSentences(itemIndex, it) }
}
fun addSection(section: Fb2Section, depth: Int) {
if (section.title.isNullOrBlank()) {
elements += ReaderElement.SectionSeparator
@ -821,6 +1112,10 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
addTextSentences(itemIndex, block.content)
elements += ReaderElement.Paragraph(block.content, depth)
}
is Fb2Block.Poem -> {
addPoemSentences(itemIndex, block.poem)
elements += ReaderElement.Poem(block.poem, depth)
}
is Fb2Block.Subtitle -> {
addTextSentences(
itemIndex = itemIndex,
@ -830,6 +1125,14 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
)
elements += ReaderElement.Subtitle(block.content)
}
is Fb2Block.Epigraph -> {
addEpigraphSentences(itemIndex, block.epigraph)
elements += ReaderElement.Epigraph(block.epigraph, depth)
}
is Fb2Block.Cite -> {
addCiteSentences(itemIndex, block.cite)
elements += ReaderElement.Cite(block.cite, depth)
}
}
}
section.sections.forEach { addSection(it, depth + 1) }
@ -837,6 +1140,11 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
elements += ReaderElement.Cover
elements += ReaderElement.FixedSpacer(6)
book.bodyEpigraphs.forEach { epigraph ->
val itemIndex = elements.size
addEpigraphSentences(itemIndex, epigraph)
elements += ReaderElement.Epigraph(epigraph, 0)
}
book.sections.forEach { addSection(it, 0) }
elements += ReaderElement.FixedSpacer(22)
@ -866,6 +1174,80 @@ internal sealed interface ReaderElement {
data class BookImage(val image: Fb2ImageRef) : ReaderElement
data class Paragraph(val text: Fb2Text, val depth: Int) : ReaderElement
data class Subtitle(val text: Fb2Text) : ReaderElement
data class Epigraph(val epigraph: Fb2Epigraph, val depth: Int) : ReaderElement
data class Cite(val cite: Fb2Cite, val depth: Int) : ReaderElement
data class Poem(val poem: Fb2Poem, val depth: Int) : ReaderElement
}
internal data class ReaderPoemSegment(
val text: Fb2Text,
val kind: ReaderPoemSegmentKind,
val startOffset: Int,
val gapBeforeDp: Int,
)
internal enum class ReaderPoemSegmentKind {
Title,
Subtitle,
Verse,
TextAuthor,
Date,
}
private fun Fb2Poem.readerSegments(): List<ReaderPoemSegment> {
val segments = mutableListOf<ReaderPoemSegment>()
var offset = 0
fun add(text: Fb2Text, kind: ReaderPoemSegmentKind, gapBeforeDp: Int) {
segments += ReaderPoemSegment(text, kind, offset, gapBeforeDp)
offset += text.plainText().length + 1
}
title.forEachIndexed { index, text ->
add(text, ReaderPoemSegmentKind.Title, if (index == 0) 0 else 2)
}
blocks.forEach { block ->
when (block) {
is Fb2PoemBlock.Subtitle -> add(block.content, ReaderPoemSegmentKind.Subtitle, 8)
is Fb2PoemBlock.Stanza -> {
val stanza = block.stanza
val startsNewStanza = segments.isNotEmpty()
stanza.title.forEachIndexed { index, text ->
add(text, ReaderPoemSegmentKind.Subtitle, if (startsNewStanza && index == 0) 12 else 4)
}
stanza.subtitle?.let { add(it, ReaderPoemSegmentKind.Subtitle, 4) }
stanza.verses.forEachIndexed { index, verse ->
val gap = when {
index == 0 && startsNewStanza && stanza.title.isEmpty() && stanza.subtitle == null -> 12
index == 0 -> 4
else -> 0
}
add(verse, ReaderPoemSegmentKind.Verse, gap)
}
}
}
}
textAuthors.forEachIndexed { index, text ->
add(text, ReaderPoemSegmentKind.TextAuthor, if (index == 0) 8 else 2)
}
date?.takeIf { it.isNotBlank() }?.let { date ->
add(Fb2Text(listOf(Fb2TextSpan(date))), ReaderPoemSegmentKind.Date, 2)
}
return segments
}
private fun ReaderSentenceRange.forSegment(segment: ReaderPoemSegment): ReaderSentenceRange? {
val segmentStart = segment.startOffset
val segmentEnd = segmentStart + segment.text.plainText().length
val overlapStart = max(start, segmentStart)
val overlapEnd = min(endExclusive, segmentEnd)
if (overlapStart >= overlapEnd) return null
return ReaderSentenceRange(
start = overlapStart - segmentStart,
endExclusive = overlapEnd - segmentStart,
pauseAfterMillis = pauseAfterMillis,
)
}
private data class ReaderSentenceRange(
@ -1006,10 +1388,42 @@ internal data class BookStats(
fun from(book: Fb2Book): BookStats {
val sections = book.sections.flattenSections()
val words = sections.sumOf { entry ->
entry.section.paragraphs.sumOf { paragraph -> paragraph.split(Regex("\\s+")).count { it.isNotBlank() } }
entry.section.readableBlocks().sumOf { block -> block.wordCount() }
}
val bodyImages = sections.sumOf { it.section.images.size } + book.bodyImages.size
return BookStats(words = words, sections = sections.size, images = bodyImages + book.coverImages.size)
}
}
}
private fun Fb2Block.wordCount(): Int =
when (this) {
Fb2Block.EmptyLine,
is Fb2Block.Image -> 0
is Fb2Block.Cite -> cite.wordCount()
is Fb2Block.Epigraph -> epigraph.wordCount()
is Fb2Block.Paragraph -> content.plainText().wordCount()
is Fb2Block.Poem -> poem.wordCount()
is Fb2Block.Subtitle -> content.plainText().wordCount()
}
private fun Fb2Poem.wordCount(): Int =
epigraphs.sumOf { it.wordCount() } + readerSegments().sumOf { it.text.plainText().wordCount() }
private fun Fb2Epigraph.wordCount(): Int =
blocks.sumOf { it.wordCount() } + textAuthors.sumOf { it.plainText().wordCount() }
private fun Fb2Cite.wordCount(): Int =
blocks.sumOf { it.wordCount() } + textAuthors.sumOf { it.plainText().wordCount() }
private fun Fb2EpigraphBlock.wordCount(): Int =
when (this) {
Fb2EpigraphBlock.EmptyLine -> 0
is Fb2EpigraphBlock.Cite -> cite.wordCount()
is Fb2EpigraphBlock.Paragraph -> content.plainText().wordCount()
is Fb2EpigraphBlock.Poem -> poem.wordCount()
is Fb2EpigraphBlock.Subtitle -> content.plainText().wordCount()
}
private fun String.wordCount(): Int =
split(Regex("\\s+")).count { it.isNotBlank() }

View File

@ -110,6 +110,15 @@ internal fun BookView(
libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = status)
if (status == BookReadingStatus.READ) markedRead = true
if (status == BookReadingStatus.NEW) markedRead = false
if (status == BookReadingStatus.NOT_INTERESTED) {
saveLibraryReadingPosition(
fileId,
ReadingPosition(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset),
)
saveActiveReadingFileId(null)
onBack()
return@launch
}
showMessage(successMessage)
} else {
showMessage(strings.couldNotUpdateBook)
@ -121,7 +130,7 @@ internal fun BookView(
val item = loadLibraryItem(fileId)
libraryItem = item
markedRead = item?.readingStatus == BookReadingStatus.READ
if (item?.readingStatus == BookReadingStatus.NEW && markLibraryReadingStatus(fileId, BookReadingStatus.READING)) {
if (item?.readingStatus?.shouldBecomeReadingOnOpen() == true && markLibraryReadingStatus(fileId, BookReadingStatus.READING)) {
libraryItem = loadLibraryItem(fileId) ?: item.copy(readingStatus = BookReadingStatus.READING)
}
}

View File

@ -5,37 +5,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals
class LibraryScreenItemListTest {
@Test
fun appendNewLibraryItemsSkipsItemsAlreadyInMemory() {
val existing = listOf(
libraryItem("file-1", "First"),
libraryItem("file-2", "Second"),
)
val page = listOf(
libraryItem("file-2", "Second duplicate"),
libraryItem("file-3", "Third"),
)
val merged = existing.appendNewLibraryItems(page)
assertEquals(listOf("file-1", "file-2", "file-3"), merged.map { it.fileId })
assertEquals("Second", merged[1].title)
}
@Test
fun appendNewLibraryItemsDeduplicatesFirstPage() {
val page = listOf(
libraryItem("file-1", "First"),
libraryItem("file-1", "First duplicate"),
libraryItem("file-2", "Second"),
)
val merged = emptyList<LibraryItem>().appendNewLibraryItems(page)
assertEquals(listOf("file-1", "file-2"), merged.map { it.fileId })
assertEquals("First", merged.first().title)
}
@Test
fun myLibraryShowsAllBooksExceptNotInterested() {
val items = listOf(

View File

@ -4,7 +4,12 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Epigraph
import net.sergeych.toread.fb2.Fb2EpigraphBlock
import net.sergeych.toread.fb2.Fb2Poem
import net.sergeych.toread.fb2.Fb2PoemBlock
import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.fb2.Fb2Stanza
import net.sergeych.toread.fb2.Fb2Text
import net.sergeych.toread.fb2.Fb2TextSpan
@ -146,6 +151,74 @@ class ReadAloudContentPlanTest {
assertEquals("/б 'z /1.", plan.sentences.single().spokenText)
}
@Test
fun poemVersesAreIncludedInReadAloudPlan() {
val plan = buildReaderContentPlan(
Fb2Book(
title = "Book",
sections = listOf(
Fb2Section(
blocks = listOf(
Fb2Block.Poem(
Fb2Poem(
blocks = listOf(
Fb2PoemBlock.Stanza(
Fb2Stanza(
verses = listOf(
text("Line one."),
text("Line two."),
),
),
),
),
),
),
),
),
),
),
)
assertEquals(listOf("Line one.", "Line two."), plan.sentences.map { it.text })
assertEquals(1, plan.elements.filterIsInstance<ReaderElement.Poem>().size)
}
@Test
fun epigraphsAreIncludedInReaderPlan() {
val plan = buildReaderContentPlan(
Fb2Book(
title = "Book",
bodyEpigraphs = listOf(
Fb2Epigraph(
blocks = listOf(Fb2EpigraphBlock.Paragraph(text("Body quote."))),
textAuthors = listOf(text("Body author.")),
),
),
sections = listOf(
Fb2Section(
blocks = listOf(
Fb2Block.Epigraph(
Fb2Epigraph(
blocks = listOf(Fb2EpigraphBlock.Paragraph(text("Section quote."))),
textAuthors = listOf(text("Section author.")),
),
),
),
),
),
),
)
assertEquals(2, plan.elements.filterIsInstance<ReaderElement.Epigraph>().size)
assertEquals(
listOf("Body quote.", "Body author.", "Section quote.", "Section author."),
plan.sentences.map { it.text },
)
}
private fun paragraph(text: String): Fb2Block.Paragraph =
Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text))))
Fb2Block.Paragraph(text(text))
private fun text(text: String): Fb2Text =
Fb2Text(listOf(Fb2TextSpan(text)))
}

View File

@ -312,9 +312,15 @@ actual suspend fun viewLibraryBookFile(fileId: String): Boolean = withContext(Di
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras()
val sourceFileName = file.originalFilename ?: file.storageUri?.substringAfterLast(File.separatorChar)
val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras(
sourceFileName = sourceFileName,
sourceFilePath = file.storageUri,
)
val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
BookInfoExtras(
sourceFileName = sourceFileName,
sourceFilePath = file.storageUri,
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
BookmarkInfo(
title = it.title,

View File

@ -15,6 +15,7 @@ data class Fb2Book(
val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(),
val bodyTitle: List<String> = emptyList(),
val bodyImages: List<Fb2ImageRef> = emptyList(),
val bodyEpigraphs: List<Fb2Epigraph> = emptyList(),
val sections: List<Fb2Section> = emptyList(),
val binaries: List<Fb2Binary> = emptyList(),
) {
@ -69,10 +70,50 @@ data class Fb2Binary(
sealed interface Fb2Block {
data class Paragraph(val content: Fb2Text) : Fb2Block
data class Subtitle(val content: Fb2Text) : Fb2Block
data class Epigraph(val epigraph: Fb2Epigraph) : Fb2Block
data class Cite(val cite: Fb2Cite) : Fb2Block
data class Poem(val poem: Fb2Poem) : Fb2Block
data class Image(val image: Fb2ImageRef) : Fb2Block
data object EmptyLine : Fb2Block
}
data class Fb2Epigraph(
val blocks: List<Fb2EpigraphBlock> = emptyList(),
val textAuthors: List<Fb2Text> = emptyList(),
)
data class Fb2Cite(
val blocks: List<Fb2EpigraphBlock> = emptyList(),
val textAuthors: List<Fb2Text> = emptyList(),
)
sealed interface Fb2EpigraphBlock {
data class Paragraph(val content: Fb2Text) : Fb2EpigraphBlock
data class Subtitle(val content: Fb2Text) : Fb2EpigraphBlock
data class Poem(val poem: Fb2Poem) : Fb2EpigraphBlock
data class Cite(val cite: Fb2Cite) : Fb2EpigraphBlock
data object EmptyLine : Fb2EpigraphBlock
}
data class Fb2Poem(
val title: List<Fb2Text> = emptyList(),
val epigraphs: List<Fb2Epigraph> = emptyList(),
val blocks: List<Fb2PoemBlock> = emptyList(),
val textAuthors: List<Fb2Text> = emptyList(),
val date: String? = null,
)
sealed interface Fb2PoemBlock {
data class Subtitle(val content: Fb2Text) : Fb2PoemBlock
data class Stanza(val stanza: Fb2Stanza) : Fb2PoemBlock
}
data class Fb2Stanza(
val title: List<Fb2Text> = emptyList(),
val subtitle: Fb2Text? = null,
val verses: List<Fb2Text> = emptyList(),
)
data class Fb2Text(
val spans: List<Fb2TextSpan>,
) {

View File

@ -32,6 +32,7 @@ internal object Fb2XmlMapper {
documentInfo = documentInfoFrom(documentInfo),
bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(),
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(),
sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
binaries = root.children("binary").mapNotNull(::binaryFrom),
)
@ -124,6 +125,9 @@ internal object Fb2XmlMapper {
when (child.localName) {
"p" -> Fb2Block.Paragraph(textFrom(child))
"subtitle" -> Fb2Block.Subtitle(textFrom(child))
"epigraph" -> Fb2Block.Epigraph(epigraphFrom(child))
"cite" -> Fb2Block.Cite(citeFrom(child))
"poem" -> Fb2Block.Poem(poemFrom(child))
"image" -> imageRefFrom(child)?.let(Fb2Block::Image)
"empty-line" -> Fb2Block.EmptyLine
else -> null
@ -140,8 +144,64 @@ internal object Fb2XmlMapper {
)
}
private fun poemFrom(element: XmlElement): Fb2Poem =
Fb2Poem(
title = titleFrom(element.first("title")),
epigraphs = element.children("epigraph").map(::epigraphFrom),
blocks = element.nodes.mapNotNull { node ->
val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null
when (child.localName) {
"subtitle" -> Fb2PoemBlock.Subtitle(textFrom(child))
"stanza" -> Fb2PoemBlock.Stanza(stanzaFrom(child))
else -> null
}
},
textAuthors = element.children("text-author").map(::textFrom),
date = element.first("date")?.text()?.ifBlank { null },
)
private fun epigraphFrom(element: XmlElement): Fb2Epigraph =
Fb2Epigraph(
blocks = element.nodes.mapNotNull { node ->
val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null
when (child.localName) {
"p" -> Fb2EpigraphBlock.Paragraph(textFrom(child))
"poem" -> Fb2EpigraphBlock.Poem(poemFrom(child))
"cite" -> Fb2EpigraphBlock.Cite(citeFrom(child))
"empty-line" -> Fb2EpigraphBlock.EmptyLine
else -> null
}
},
textAuthors = element.children("text-author").map(::textFrom),
)
private fun citeFrom(element: XmlElement): Fb2Cite =
Fb2Cite(
blocks = element.nodes.mapNotNull { node ->
val child = (node as? XmlNode.ElementNode)?.element ?: return@mapNotNull null
when (child.localName) {
"p" -> Fb2EpigraphBlock.Paragraph(textFrom(child))
"subtitle" -> Fb2EpigraphBlock.Subtitle(textFrom(child))
"poem" -> Fb2EpigraphBlock.Poem(poemFrom(child))
"empty-line" -> Fb2EpigraphBlock.EmptyLine
else -> null
}
},
textAuthors = element.children("text-author").map(::textFrom),
)
private fun stanzaFrom(element: XmlElement): Fb2Stanza =
Fb2Stanza(
title = titleFrom(element.first("title")),
subtitle = element.first("subtitle")?.let(::textFrom),
verses = element.children("v").map(::textFrom),
)
private fun titleFrom(element: XmlElement?): List<Fb2Text> =
element?.children("p")?.map(::textFrom).orEmpty()
private fun textFrom(element: XmlElement): Fb2Text =
Fb2Text(spansFrom(element.nodes).mergeAdjacent())
Fb2Text(spansFrom(element.nodes).mergeAdjacent().trimBoundaryWhitespace())
private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> =
nodes.flatMap { node ->
@ -179,6 +239,14 @@ internal object Fb2XmlMapper {
return merged
}
private fun List<Fb2TextSpan>.trimBoundaryWhitespace(): List<Fb2TextSpan> {
if (isEmpty()) return emptyList()
val trimmed = toMutableList()
trimmed[0] = trimmed[0].copy(text = trimmed[0].text.trimStart())
trimmed[trimmed.lastIndex] = trimmed.last().copy(text = trimmed.last().text.trimEnd())
return trimmed.filter { it.text.isNotEmpty() }
}
private fun StringBuilder.appendSection(section: Fb2Section) {
append("<section>")
section.title?.takeIf { it.isNotBlank() }?.let {

View File

@ -81,6 +81,41 @@ class Fb2FormatTest {
assertEquals("pic.png", (section.blocks[3] as Fb2Block.Image).image.binaryId)
}
@Test
fun parsesPoemsWithStanzasVersesAndInlineStyles() {
val book = Fb2Format.parseXml(poemXml)
val poem = (book.sections.single().blocks.single() as Fb2Block.Poem).poem
assertEquals("Song", poem.title.single().plainText)
val stanza = (poem.blocks.single() as Fb2PoemBlock.Stanza).stanza
assertEquals("First stanza", stanza.title.single().plainText)
assertEquals("Softly", stanza.subtitle?.plainText)
assertEquals(listOf("Line one", "Line two."), stanza.verses.map { it.plainText })
assertEquals(setOf(Fb2TextStyle.Emphasis), stanza.verses[0].spans.single().styles)
assertEquals("Poet", poem.textAuthors.single().plainText)
assertEquals("1910", poem.date)
}
@Test
fun parsesBodySectionAndPoemEpigraphs() {
val book = Fb2Format.parseXml(epigraphXml)
assertEquals("Body quote", (book.bodyEpigraphs.single().blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
val sectionBlocks = book.sections.single().blocks
val sectionEpigraph = (sectionBlocks[0] as Fb2Block.Epigraph).epigraph
assertEquals("Section quote", (sectionEpigraph.blocks[0] as Fb2EpigraphBlock.Paragraph).content.plainText)
assertEquals("Author", sectionEpigraph.textAuthors.single().plainText)
val cite = (sectionEpigraph.blocks[1] as Fb2EpigraphBlock.Cite).cite
assertEquals("Cited line", (cite.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
assertEquals("Cited author", cite.textAuthors.single().plainText)
val poem = (sectionBlocks[1] as Fb2Block.Poem).poem
val poemEpigraph = poem.epigraphs.single()
assertEquals("Poem quote", (poemEpigraph.blocks.single() as Fb2EpigraphBlock.Paragraph).content.plainText)
}
private val sampleXml = """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
@ -134,6 +169,78 @@ class Fb2FormatTest {
</FictionBook>
""".trimIndent()
private val poemXml = """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
<title-info>
<author><nickname>A</nickname></author>
<book-title>Poetry</book-title>
<lang>en</lang>
</title-info>
<document-info>
<author><nickname>Toread</nickname></author>
<date>2026-05-12</date>
<id>poetry</id>
<version>1.0</version>
</document-info>
</description>
<body>
<section>
<poem>
<title><p>Song</p></title>
<stanza>
<title><p>First stanza</p></title>
<subtitle>Softly</subtitle>
<v>
<emphasis>Line one</emphasis>
</v>
<v>Line two.</v>
</stanza>
<text-author>Poet</text-author>
<date>1910</date>
</poem>
</section>
</body>
</FictionBook>
""".trimIndent()
private val epigraphXml = """
<?xml version="1.0" encoding="UTF-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<description>
<title-info>
<author><nickname>A</nickname></author>
<book-title>Epigraphs</book-title>
<lang>en</lang>
</title-info>
<document-info>
<author><nickname>Toread</nickname></author>
<date>2026-05-12</date>
<id>epigraphs</id>
<version>1.0</version>
</document-info>
</description>
<body>
<epigraph><p>Body quote</p></epigraph>
<section>
<epigraph>
<p>Section quote</p>
<cite>
<p>Cited line</p>
<text-author>Cited author</text-author>
</cite>
<text-author>Author</text-author>
</epigraph>
<poem>
<epigraph><p>Poem quote</p></epigraph>
<stanza><v>Line.</v></stanza>
</poem>
</section>
</body>
</FictionBook>
""".trimIndent()
private val windows1251Xml = """
<?xml version="1.0" encoding="windows-1251"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:xlink="http://www.w3.org/1999/xlink">

View File

@ -2,7 +2,12 @@ package net.sergeych.toread.storage.jdbc
import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book
import net.sergeych.toread.fb2.Fb2Cite
import net.sergeych.toread.fb2.Fb2Epigraph
import net.sergeych.toread.fb2.Fb2EpigraphBlock
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.fb2.Fb2Poem
import net.sergeych.toread.fb2.Fb2PoemBlock
import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BookBodyRecord
@ -246,7 +251,7 @@ private fun String.bookMimeType(): String =
if (endsWith(".zip", ignoreCase = true)) "application/zip" else "application/x-fictionbook+xml"
private fun Fb2Book.canonicalText(): String =
sections.flatMap { it.textBlocks() }
(bodyEpigraphs.flatMap { it.textBlocks() } + sections.flatMap { it.textBlocks() })
.joinToString(separator = "\n") { it.normalizeForBodyHash() }
.trim()
@ -256,8 +261,11 @@ private fun Fb2Section.textBlocks(): List<String> {
blocks.forEach { block ->
when (block) {
Fb2Block.EmptyLine -> Unit
is Fb2Block.Cite -> addAll(block.cite.textBlocks())
is Fb2Block.Epigraph -> addAll(block.epigraph.textBlocks())
is Fb2Block.Image -> Unit
is Fb2Block.Paragraph -> add(block.content.plainText)
is Fb2Block.Poem -> addAll(block.poem.textBlocks())
is Fb2Block.Subtitle -> add(block.content.plainText)
}
}
@ -266,6 +274,42 @@ private fun Fb2Section.textBlocks(): List<String> {
return current + sections.flatMap { it.textBlocks() }
}
private fun Fb2Poem.textBlocks(): List<String> = buildList {
title.forEach { add(it.plainText) }
epigraphs.forEach { addAll(it.textBlocks()) }
blocks.forEach { block ->
when (block) {
is Fb2PoemBlock.Stanza -> {
block.stanza.title.forEach { add(it.plainText) }
block.stanza.subtitle?.let { add(it.plainText) }
block.stanza.verses.forEach { add(it.plainText) }
}
is Fb2PoemBlock.Subtitle -> add(block.content.plainText)
}
}
textAuthors.forEach { add(it.plainText) }
date?.let { add(it) }
}
private fun Fb2Epigraph.textBlocks(): List<String> = buildList {
blocks.forEach { addAll(it.textBlocks()) }
textAuthors.forEach { add(it.plainText) }
}
private fun Fb2Cite.textBlocks(): List<String> = buildList {
blocks.forEach { addAll(it.textBlocks()) }
textAuthors.forEach { add(it.plainText) }
}
private fun Fb2EpigraphBlock.textBlocks(): List<String> =
when (this) {
Fb2EpigraphBlock.EmptyLine -> emptyList()
is Fb2EpigraphBlock.Cite -> cite.textBlocks()
is Fb2EpigraphBlock.Paragraph -> listOf(content.plainText)
is Fb2EpigraphBlock.Poem -> poem.textBlocks()
is Fb2EpigraphBlock.Subtitle -> listOf(content.plainText)
}
private fun String.normalizeForBodyHash(): String =
lowercase()
.replace('\u00ad'.toString(), "")