Compare commits
2 Commits
316b10d015
...
c7b7ca68ab
| Author | SHA1 | Date | |
|---|---|---|---|
| c7b7ca68ab | |||
| de04b9cbed |
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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() }
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 =
|
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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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(), "")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user