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) { actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() 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() val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
BookInfoExtras( BookInfoExtras(
sourceFileName = sourceFileName,
sourceFilePath = file.storageUri,
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map { bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
BookmarkInfo( BookmarkInfo(
title = it.title, 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 { item {
InfoSection(strings.lastReadingPosition) { InfoSection(strings.lastReadingPosition) {
val position = extras?.lastReadingPosition val position = extras?.lastReadingPosition

View File

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

View File

@ -97,9 +97,7 @@ internal fun LibraryScreen(
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) } var message by remember(state.message) { mutableStateOf(state.message) }
var items by remember(state.items) { mutableStateOf(state.items) } var items by remember(state.items) { mutableStateOf(state.items) }
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) } var loadingLibrary by remember(state.items) { mutableStateOf(false) }
var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached by remember(state.items) { mutableStateOf(false) }
var recentlyAddedItems by remember(state.items) { mutableStateOf<List<LibraryItem>>(emptyList()) } var recentlyAddedItems by remember(state.items) { mutableStateOf<List<LibraryItem>>(emptyList()) }
var wasScanning by remember { mutableStateOf(false) } var wasScanning by remember { mutableStateOf(false) }
var settingsMenuOpen 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 recentlyAdded = recentlyAddedItems.filterNot { it.fileId in hiddenFileIds }
val visibleItems = selectedFilter.apply(sourceItems, recentlyAdded, searchActive) val visibleItems = selectedFilter.apply(sourceItems, recentlyAdded, searchActive)
.withoutDuplicateFileIds() .withoutDuplicateFileIds()
val canLoadMore = !searchActive && selectedFilter.usesPagedLibrary && !endReached
suspend fun loadPage(reset: Boolean = false) { suspend fun loadLibrary() {
if (loadingPage) return if (loadingLibrary) return
loadingPage = true loadingLibrary = true
val previousItems = items val previousItems = items
if (reset) {
nextOffset = 0
endReached = false
}
val offset = if (reset) 0 else nextOffset
try { try {
val limit = if (reset) maxOf(LibraryPageSize, previousItems.size) else LibraryPageSize val loadedItems = loadLibraryItems()
val page = loadLibraryItemsPage(limit, offset) if (loadedItems != previousItems) {
if (reset) { items = loadedItems
if (page != previousItems) {
items = page
} }
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 -> coverCache.keys.toList().forEach { fileId ->
if (fileId !in visibleFileIds) coverCache.remove(fileId) if (fileId !in visibleFileIds) coverCache.remove(fileId)
} }
} else {
items = items.appendNewLibraryItems(page)
}
nextOffset = offset + page.size
endReached = page.size < limit
} catch (t: Throwable) { } catch (t: Throwable) {
message = t.message ?: strings.couldNotLoadLibrary message = t.message ?: strings.couldNotLoadLibrary
endReached = true
} finally { } finally {
loadingPage = false loadingLibrary = false
} }
} }
@ -169,7 +155,7 @@ internal fun LibraryScreen(
searchResults = searchLibraryItems(searchText, SearchResultLimit) searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false searching = false
} else { } else {
loadPage(reset = true) loadLibrary()
loadRecentlyAdded() loadRecentlyAdded()
} }
} }
@ -191,7 +177,7 @@ internal fun LibraryScreen(
searchResults = searchLibraryItems(searchText, SearchResultLimit) searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false searching = false
} else { } else {
loadPage(reset = true) loadLibrary()
loadRecentlyAdded() loadRecentlyAdded()
} }
} else { } else {
@ -215,7 +201,7 @@ internal fun LibraryScreen(
searchResults = searchLibraryItems(searchText, SearchResultLimit) searchResults = searchLibraryItems(searchText, SearchResultLimit)
searching = false searching = false
} else { } else {
loadPage(reset = true) loadLibrary()
loadRecentlyAdded() loadRecentlyAdded()
} }
} else { } else {
@ -255,8 +241,8 @@ internal fun LibraryScreen(
} }
LaunchedEffect(state.scanPath, state.message) { LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) { if (items.isEmpty()) {
loadPage(reset = true) loadLibrary()
loadRecentlyAdded() loadRecentlyAdded()
} }
} }
@ -293,9 +279,9 @@ internal fun LibraryScreen(
} }
} }
LaunchedEffect(searchActive, loadingPage, endReached, libraryItems, recentlyAdded) { LaunchedEffect(searchActive, loadingLibrary, libraryItems, recentlyAdded) {
val libraryDataLoaded = endReached || libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty() val libraryDataLoaded = libraryItems.isNotEmpty() || recentlyAdded.isNotEmpty()
if (!filterChosenByUser && !searchActive && !loadingPage && libraryDataLoaded) { if (!filterChosenByUser && !searchActive && !loadingLibrary && libraryDataLoaded) {
selectedFilter = defaultLibraryFilter(libraryItems, recentlyAdded) selectedFilter = defaultLibraryFilter(libraryItems, recentlyAdded)
} }
} }
@ -328,12 +314,12 @@ internal fun LibraryScreen(
wasScanning = true wasScanning = true
while (true) { while (true) {
delay(2_000) delay(2_000)
loadPage(reset = true) loadLibrary()
loadRecentlyAdded() loadRecentlyAdded()
} }
} else if (wasScanning) { } else if (wasScanning) {
wasScanning = false wasScanning = false
loadPage(reset = true) loadLibrary()
loadRecentlyAdded() loadRecentlyAdded()
} }
} }
@ -486,11 +472,11 @@ internal fun LibraryScreen(
.background(readerBackground()), .background(readerBackground()),
) { ) {
val wide = maxWidth >= 800.dp val wide = maxWidth >= 800.dp
if (visibleItems.isEmpty() && (loadingPage || searching)) { if (visibleItems.isEmpty() && (loadingLibrary || searching)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()
} }
} else if (visibleItems.isEmpty() && !canLoadMore) { } else if (visibleItems.isEmpty()) {
if (searchActive) { if (searchActive) {
EmptySearchPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp)) EmptySearchPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else { } else {
@ -520,7 +506,7 @@ internal fun LibraryScreen(
readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem) readerLibraryItems = readerLibraryItems.replaceLibraryItem(updatedItem)
coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId) coverCache[updatedItem.fileId] = loadLibraryItemCover(updatedItem.fileId)
} }
if (item.readingStatus == BookReadingStatus.NEW && if (item.readingStatus.shouldBecomeReadingOnOpen() &&
markLibraryReadingStatus(item.fileId, BookReadingStatus.READING) markLibraryReadingStatus(item.fileId, BookReadingStatus.READING)
) { ) {
val readingItem = loadLibraryItem(item.fileId) val readingItem = loadLibraryItem(item.fileId)
@ -613,7 +599,6 @@ internal fun LibraryScreen(
val previousRecentlyAddedItems = recentlyAddedItems val previousRecentlyAddedItems = recentlyAddedItems
val previousCover = coverCache[item.fileId] val previousCover = coverCache[item.fileId]
val hadCover = coverCache.containsKey(item.fileId) val hadCover = coverCache.containsKey(item.fileId)
val previousNextOffset = nextOffset
onDeleteRequested( onDeleteRequested(
LibraryDeleteRequest(item.fileId, item.title), LibraryDeleteRequest(item.fileId, item.title),
{ {
@ -622,7 +607,6 @@ internal fun LibraryScreen(
searchResults = searchResults.filterNot { it.fileId == item.fileId } searchResults = searchResults.filterNot { it.fileId == item.fileId }
recentlyAddedItems = recentlyAddedItems.filterNot { it.fileId == item.fileId } recentlyAddedItems = recentlyAddedItems.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId) coverCache.remove(item.fileId)
nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
}, },
{ {
items = previousItems items = previousItems
@ -631,7 +615,6 @@ internal fun LibraryScreen(
if (hadCover) { if (hadCover) {
coverCache[item.fileId] = previousCover coverCache[item.fileId] = previousCover
} }
nextOffset = previousNextOffset
}, },
) )
}, },
@ -649,20 +632,6 @@ internal fun LibraryScreen(
} }
libraryRows(visibleItems) 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 -> activeScan?.let { progress ->
@ -1002,9 +971,9 @@ private data class LibraryItemActions(
val onDelete: () -> Unit, val onDelete: () -> Unit,
) )
private enum class LibraryFilter(val usesPagedLibrary: Boolean = true) { private enum class LibraryFilter {
ReadingNow, ReadingNow,
RecentlyAdded(usesPagedLibrary = false), RecentlyAdded,
MyLibrary, MyLibrary,
ToRead, ToRead,
Favorites, Favorites,
@ -1193,15 +1162,12 @@ private fun Long.formatBytes(): String =
private fun List<LibraryItem>.replaceLibraryItem(item: LibraryItem): List<LibraryItem> = private fun List<LibraryItem>.replaceLibraryItem(item: LibraryItem): List<LibraryItem> =
map { current -> if (current.fileId == item.fileId) item else current } 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> = internal fun List<LibraryItem>.withoutDuplicateFileIds(): List<LibraryItem> =
distinctBy { it.fileId } 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 Key.isEnterKey(): Boolean = this == Key.Enter || this == Key.NumPadEnter
private fun LibraryScanProgress.toCatalogScanMessage(): String { private fun LibraryScanProgress.toCatalogScanMessage(): String {
@ -1214,7 +1180,6 @@ private fun LibraryScanProgress.toCatalogScanMessage(): String {
return strings.scannedProgress(scannedFiles, total, percent) return strings.scannedProgress(scannedFiles, total, percent)
} }
private const val LibraryPageSize: Int = 50
private const val SearchResultLimit: Int = 100 private const val SearchResultLimit: Int = 100
private const val RecentlyAddedLimit: Int = 50 private const val RecentlyAddedLimit: Int = 50
private const val RecentlyAddedWindowMillis: Long = 30L * 60L * 60L * 1000L 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 words = "Words"
open val sections = "Sections" open val sections = "Sections"
open val images = "Images" 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 lastReadingPosition = "Last Reading Position"
open val noSavedPosition = "No saved position" open val noSavedPosition = "No saved position"
open val listItem = "List item" open val listItem = "List item"
@ -243,7 +246,7 @@ internal object RussianStrings : AppStrings() {
override val couldNotOpenBook = "Не удалось открыть книгу." override val couldNotOpenBook = "Не удалось открыть книгу."
override val couldNotUpdateBook = "Не удалось обновить книгу." override val couldNotUpdateBook = "Не удалось обновить книгу."
override val bookFileNotAvailable = "Файл книги недоступен." override val bookFileNotAvailable = "Файл книги недоступен."
override val scanFailed = "Сканирование не удалось." override val scanFailed = "Импорт не удался."
override val searchFailed = "Поиск не удался." override val searchFailed = "Поиск не удался."
override val libraryRescanFailed = "Повторное сканирование библиотеки не удалось." override val libraryRescanFailed = "Повторное сканирование библиотеки не удалось."
override val rescanningLibrary = "Пересканируем библиотеку..." override val rescanningLibrary = "Пересканируем библиотеку..."
@ -264,7 +267,7 @@ internal object RussianStrings : AppStrings() {
override val closeSearch = "Закрыть поиск" override val closeSearch = "Закрыть поиск"
override val clearSearch = "Очистить поиск" override val clearSearch = "Очистить поиск"
override val noMatches = "Ничего не найдено" override val noMatches = "Ничего не найдено"
override val scanFolderOrChooseFilter = "Просканируйте папку или выберите другой фильтр библиотеки." override val scanFolderOrChooseFilter = "Импортируйте папку или выберите другой фильтр библиотеки."
override val favorite = "Избранное" override val favorite = "Избранное"
override val unknownAuthor = "Автор неизвестен" override val unknownAuthor = "Автор неизвестен"
override val noMetadata = "Нет метаданных" override val noMetadata = "Нет метаданных"
@ -287,7 +290,7 @@ internal object RussianStrings : AppStrings() {
override val filterToRead = "К чтению" override val filterToRead = "К чтению"
override val filterRead = "Прочитанные" override val filterRead = "Прочитанные"
override val scan = "Сканирование" override val scan = "Импорт"
override val rootFolder = "Корневая папка" override val rootFolder = "Корневая папка"
override val choose = "Выбрать" override val choose = "Выбрать"
override val logPrefix = "Лог" override val logPrefix = "Лог"
@ -309,6 +312,9 @@ internal object RussianStrings : AppStrings() {
override val words = "Слова" override val words = "Слова"
override val sections = "Разделы" override val sections = "Разделы"
override val images = "Иллюстрации" override val images = "Иллюстрации"
override val sourceFile = "Исходный файл"
override val fileName = "Имя файла"
override val filePath = "Путь"
override val lastReadingPosition = "Последняя позиция чтения" override val lastReadingPosition = "Последняя позиция чтения"
override val noSavedPosition = "Позиция не сохранена" override val noSavedPosition = "Позиция не сохранена"
override val listItem = "Элемент списка" override val listItem = "Элемент списка"
@ -378,9 +384,9 @@ internal object RussianStrings : AppStrings() {
} }
override fun importedSkippedFailed(imported: Int, skipped: Int, failed: Int): String = override fun importedSkippedFailed(imported: Int, skipped: Int, failed: Int): String =
"Импортировано: $imported, пропущено: $skipped, ошибок: $failed" "Импортировано: $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 = 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 noBooksIn(filterLabel: String): String = "Нет книг: ${filterLabel.lowercase()}"
override fun bookMenuFor(title: String): String = "Меню книги: $title" override fun bookMenuFor(title: String): String = "Меню книги: $title"
override fun markedAsRead(title: String?): String = 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 androidx.compose.ui.unit.sp
import net.sergeych.toread.fb2.Fb2Block import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book 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.Fb2ImageRef
import net.sergeych.toread.fb2.Fb2Poem
import net.sergeych.toread.fb2.Fb2PoemBlock
import net.sergeych.toread.fb2.Fb2Section import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.fb2.Fb2Text import net.sergeych.toread.fb2.Fb2Text
import net.sergeych.toread.fb2.Fb2TextSpan import net.sergeych.toread.fb2.Fb2TextSpan
@ -210,6 +215,27 @@ internal fun ContinuousBookReader(
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp),
onTextLayout = { textLineMetricsByItem[itemIndex] = it.toTextLineMetrics() }, 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), style = readerParagraphTextStyle(book.language),
textAlign = TextAlign.Unspecified, textAlign = TextAlign.Unspecified,
) )
is Fb2Block.Poem -> ReaderPoem(
poem = block.poem,
language = book.language,
hyphenation = hyphenation,
highlightedRange = null,
depth = 0,
)
is Fb2Block.Subtitle -> ReaderText( is Fb2Block.Subtitle -> ReaderText(
text = block.content, text = block.content,
language = book.language, language = book.language,
@ -475,6 +508,20 @@ private fun ReaderPane(book: Fb2Book, section: Fb2Section?, modifier: Modifier =
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 8.dp), 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)) } 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 @Composable
private fun readerParagraphTextStyle(language: String?): TextStyle = private fun readerParagraphTextStyle(language: String?): TextStyle =
MaterialTheme.typography.bodyLarge.copy( MaterialTheme.typography.bodyLarge.copy(
@ -766,6 +1019,7 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
fun addTextSentences( fun addTextSentences(
itemIndex: Int, itemIndex: Int,
text: Fb2Text, text: Fb2Text,
offset: Int = 0,
pauseBeforeMillis: Long = 0, pauseBeforeMillis: Long = 0,
pauseAfterMillis: Long = 0, pauseAfterMillis: Long = 0,
) { ) {
@ -784,8 +1038,8 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
sentences += ReadAloudSentence( sentences += ReadAloudSentence(
index = sentences.size, index = sentences.size,
itemIndex = itemIndex, itemIndex = itemIndex,
start = range.start, start = offset + range.start,
endExclusive = range.endExclusive, endExclusive = offset + range.endExclusive,
text = sentenceText, text = sentenceText,
spokenText = spokenText, spokenText = spokenText,
pauseBeforeMillis = effectivePauseBefore, 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) { fun addSection(section: Fb2Section, depth: Int) {
if (section.title.isNullOrBlank()) { if (section.title.isNullOrBlank()) {
elements += ReaderElement.SectionSeparator elements += ReaderElement.SectionSeparator
@ -821,6 +1112,10 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
addTextSentences(itemIndex, block.content) addTextSentences(itemIndex, block.content)
elements += ReaderElement.Paragraph(block.content, depth) elements += ReaderElement.Paragraph(block.content, depth)
} }
is Fb2Block.Poem -> {
addPoemSentences(itemIndex, block.poem)
elements += ReaderElement.Poem(block.poem, depth)
}
is Fb2Block.Subtitle -> { is Fb2Block.Subtitle -> {
addTextSentences( addTextSentences(
itemIndex = itemIndex, itemIndex = itemIndex,
@ -830,6 +1125,14 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
) )
elements += ReaderElement.Subtitle(block.content) 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) } section.sections.forEach { addSection(it, depth + 1) }
@ -837,6 +1140,11 @@ internal fun buildReaderContentPlan(book: Fb2Book): ReaderContentPlan {
elements += ReaderElement.Cover elements += ReaderElement.Cover
elements += ReaderElement.FixedSpacer(6) 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) } book.sections.forEach { addSection(it, 0) }
elements += ReaderElement.FixedSpacer(22) elements += ReaderElement.FixedSpacer(22)
@ -866,6 +1174,80 @@ internal sealed interface ReaderElement {
data class BookImage(val image: Fb2ImageRef) : ReaderElement data class BookImage(val image: Fb2ImageRef) : ReaderElement
data class Paragraph(val text: Fb2Text, val depth: Int) : ReaderElement data class Paragraph(val text: Fb2Text, val depth: Int) : ReaderElement
data class Subtitle(val text: Fb2Text) : 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( private data class ReaderSentenceRange(
@ -1006,10 +1388,42 @@ internal data class BookStats(
fun from(book: Fb2Book): BookStats { fun from(book: Fb2Book): BookStats {
val sections = book.sections.flattenSections() val sections = book.sections.flattenSections()
val words = sections.sumOf { entry -> 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 val bodyImages = sections.sumOf { it.section.images.size } + book.bodyImages.size
return BookStats(words = words, sections = sections.size, images = bodyImages + book.coverImages.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) libraryItem = loadLibraryItem(fileId) ?: libraryItem?.copy(readingStatus = status)
if (status == BookReadingStatus.READ) markedRead = true if (status == BookReadingStatus.READ) markedRead = true
if (status == BookReadingStatus.NEW) markedRead = false 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) showMessage(successMessage)
} else { } else {
showMessage(strings.couldNotUpdateBook) showMessage(strings.couldNotUpdateBook)
@ -121,7 +130,7 @@ internal fun BookView(
val item = loadLibraryItem(fileId) val item = loadLibraryItem(fileId)
libraryItem = item libraryItem = item
markedRead = item?.readingStatus == BookReadingStatus.READ 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) libraryItem = loadLibraryItem(fileId) ?: item.copy(readingStatus = BookReadingStatus.READING)
} }
} }

View File

@ -5,37 +5,6 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
class LibraryScreenItemListTest { 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 @Test
fun myLibraryShowsAllBooksExceptNotInterested() { fun myLibraryShowsAllBooksExceptNotInterested() {
val items = listOf( val items = listOf(

View File

@ -4,7 +4,12 @@ import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import net.sergeych.toread.fb2.Fb2Block import net.sergeych.toread.fb2.Fb2Block
import net.sergeych.toread.fb2.Fb2Book 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.Fb2Section
import net.sergeych.toread.fb2.Fb2Stanza
import net.sergeych.toread.fb2.Fb2Text import net.sergeych.toread.fb2.Fb2Text
import net.sergeych.toread.fb2.Fb2TextSpan import net.sergeych.toread.fb2.Fb2TextSpan
@ -146,6 +151,74 @@ class ReadAloudContentPlanTest {
assertEquals("/б 'z /1.", plan.sentences.single().spokenText) assertEquals("/б 'z /1.", plan.sentences.single().spokenText)
} }
private fun paragraph(text: String): Fb2Block.Paragraph = @Test
Fb2Block.Paragraph(Fb2Text(listOf(Fb2TextSpan(text)))) 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(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) { actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db -> openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras() 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() val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
BookInfoExtras( BookInfoExtras(
sourceFileName = sourceFileName,
sourceFilePath = file.storageUri,
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map { bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
BookmarkInfo( BookmarkInfo(
title = it.title, title = it.title,

View File

@ -15,6 +15,7 @@ data class Fb2Book(
val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(), val documentInfo: Fb2DocumentInfo = Fb2DocumentInfo(),
val bodyTitle: List<String> = emptyList(), val bodyTitle: List<String> = emptyList(),
val bodyImages: List<Fb2ImageRef> = emptyList(), val bodyImages: List<Fb2ImageRef> = emptyList(),
val bodyEpigraphs: List<Fb2Epigraph> = emptyList(),
val sections: List<Fb2Section> = emptyList(), val sections: List<Fb2Section> = emptyList(),
val binaries: List<Fb2Binary> = emptyList(), val binaries: List<Fb2Binary> = emptyList(),
) { ) {
@ -69,10 +70,50 @@ data class Fb2Binary(
sealed interface Fb2Block { sealed interface Fb2Block {
data class Paragraph(val content: Fb2Text) : Fb2Block data class Paragraph(val content: Fb2Text) : Fb2Block
data class Subtitle(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 class Image(val image: Fb2ImageRef) : Fb2Block
data object EmptyLine : 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( data class Fb2Text(
val spans: List<Fb2TextSpan>, val spans: List<Fb2TextSpan>,
) { ) {

View File

@ -32,6 +32,7 @@ internal object Fb2XmlMapper {
documentInfo = documentInfoFrom(documentInfo), documentInfo = documentInfoFrom(documentInfo),
bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(), bodyTitle = body?.first("title")?.children("p")?.mapNotNull { it.text().ifBlank { null } }.orEmpty(),
bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(), bodyImages = body?.children("image")?.mapNotNull(::imageRefFrom).orEmpty(),
bodyEpigraphs = body?.children("epigraph")?.map(::epigraphFrom).orEmpty(),
sections = body?.children("section")?.map(::sectionFrom).orEmpty(), sections = body?.children("section")?.map(::sectionFrom).orEmpty(),
binaries = root.children("binary").mapNotNull(::binaryFrom), binaries = root.children("binary").mapNotNull(::binaryFrom),
) )
@ -124,6 +125,9 @@ internal object Fb2XmlMapper {
when (child.localName) { when (child.localName) {
"p" -> Fb2Block.Paragraph(textFrom(child)) "p" -> Fb2Block.Paragraph(textFrom(child))
"subtitle" -> Fb2Block.Subtitle(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) "image" -> imageRefFrom(child)?.let(Fb2Block::Image)
"empty-line" -> Fb2Block.EmptyLine "empty-line" -> Fb2Block.EmptyLine
else -> null 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 = 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> = private fun spansFrom(nodes: List<XmlNode>, styles: Set<Fb2TextStyle> = emptySet()): List<Fb2TextSpan> =
nodes.flatMap { node -> nodes.flatMap { node ->
@ -179,6 +239,14 @@ internal object Fb2XmlMapper {
return merged 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) { private fun StringBuilder.appendSection(section: Fb2Section) {
append("<section>") append("<section>")
section.title?.takeIf { it.isNotBlank() }?.let { 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) 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 = """ private val sampleXml = """
<?xml version="1.0" encoding="UTF-8"?> <?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"> <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> </FictionBook>
""".trimIndent() """.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 = """ private val windows1251Xml = """
<?xml version="1.0" encoding="windows-1251"?> <?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"> <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.Fb2Block
import net.sergeych.toread.fb2.Fb2Book 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.Fb2Format
import net.sergeych.toread.fb2.Fb2Poem
import net.sergeych.toread.fb2.Fb2PoemBlock
import net.sergeych.toread.fb2.Fb2Section import net.sergeych.toread.fb2.Fb2Section
import net.sergeych.toread.storage.BodyClusterRecord import net.sergeych.toread.storage.BodyClusterRecord
import net.sergeych.toread.storage.BookBodyRecord 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" if (endsWith(".zip", ignoreCase = true)) "application/zip" else "application/x-fictionbook+xml"
private fun Fb2Book.canonicalText(): String = private fun Fb2Book.canonicalText(): String =
sections.flatMap { it.textBlocks() } (bodyEpigraphs.flatMap { it.textBlocks() } + sections.flatMap { it.textBlocks() })
.joinToString(separator = "\n") { it.normalizeForBodyHash() } .joinToString(separator = "\n") { it.normalizeForBodyHash() }
.trim() .trim()
@ -256,8 +261,11 @@ private fun Fb2Section.textBlocks(): List<String> {
blocks.forEach { block -> blocks.forEach { block ->
when (block) { when (block) {
Fb2Block.EmptyLine -> Unit Fb2Block.EmptyLine -> Unit
is Fb2Block.Cite -> addAll(block.cite.textBlocks())
is Fb2Block.Epigraph -> addAll(block.epigraph.textBlocks())
is Fb2Block.Image -> Unit is Fb2Block.Image -> Unit
is Fb2Block.Paragraph -> add(block.content.plainText) is Fb2Block.Paragraph -> add(block.content.plainText)
is Fb2Block.Poem -> addAll(block.poem.textBlocks())
is Fb2Block.Subtitle -> add(block.content.plainText) is Fb2Block.Subtitle -> add(block.content.plainText)
} }
} }
@ -266,6 +274,42 @@ private fun Fb2Section.textBlocks(): List<String> {
return current + sections.flatMap { it.textBlocks() } 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 = private fun String.normalizeForBodyHash(): String =
lowercase() lowercase()
.replace('\u00ad'.toString(), "") .replace('\u00ad'.toString(), "")