refactor: cover parser

This commit is contained in:
Acclorite 2025-11-12 10:12:58 +02:00
parent c76f7f38d5
commit 9e17d86588
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
33 changed files with 477 additions and 305 deletions

View file

@ -22,6 +22,8 @@ import ua.acclorite.book_story.data.mapper.file.FileMapper
import ua.acclorite.book_story.data.mapper.file.FileMapperImpl import ua.acclorite.book_story.data.mapper.file.FileMapperImpl
import ua.acclorite.book_story.data.mapper.history.HistoryMapper import ua.acclorite.book_story.data.mapper.history.HistoryMapper
import ua.acclorite.book_story.data.mapper.history.HistoryMapperImpl import ua.acclorite.book_story.data.mapper.history.HistoryMapperImpl
import ua.acclorite.book_story.data.parser.cover.CoverParser
import ua.acclorite.book_story.data.parser.cover.CoverParserImpl
import ua.acclorite.book_story.data.parser.file.FileParser import ua.acclorite.book_story.data.parser.file.FileParser
import ua.acclorite.book_story.data.parser.file.FileParserImpl import ua.acclorite.book_story.data.parser.file.FileParserImpl
import ua.acclorite.book_story.data.parser.text.TextParser import ua.acclorite.book_story.data.parser.text.TextParser
@ -121,6 +123,12 @@ abstract class RepositoryModule {
fileParserImpl: FileParserImpl fileParserImpl: FileParserImpl
): FileParser ): FileParser
@Binds
@Singleton
abstract fun bindCoverParser(
coverParserImpl: CoverParserImpl
): CoverParser
@Binds @Binds
@Singleton @Singleton
abstract fun bindTextParser( abstract fun bindTextParser(

View file

@ -1,17 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.model.common
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.domain.model.library.Book
@Immutable
data class BookWithCover(
val book: Book,
val coverImage: CoverImage?
)

View file

@ -1,34 +0,0 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.model.common
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.core.ui.UIText
@Immutable
sealed class NullableBook(
val bookWithCover: BookWithCover?,
val fileName: String?,
val message: UIText?
) {
class NotNull(
bookWithCover: BookWithCover
) : NullableBook(
bookWithCover = bookWithCover,
fileName = null,
message = null
)
class Null(
fileName: String,
message: UIText?
) : NullableBook(
bookWithCover = null,
fileName = fileName,
message = message
)
}

View file

@ -0,0 +1,14 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.cover
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.data.model.file.CachedFile
interface CoverParser {
suspend fun parse(cachedFile: CachedFile): CoverImage?
}

View file

@ -0,0 +1,64 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.cover
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.core.log.logE
import ua.acclorite.book_story.data.model.file.CachedFile
import javax.inject.Inject
class CoverParserImpl @Inject constructor(
private val epubCoverParser: EpubCoverParser,
private val txtCoverParser: TxtCoverParser,
private val pdfCoverParser: PdfCoverParser,
private val fb2CoverParser: Fb2CoverParser,
private val htmlCoverParser: HtmlCoverParser
) : CoverParser {
override suspend fun parse(cachedFile: CachedFile): CoverImage? {
if (!cachedFile.canAccess()) {
logE("File does not exist or no read access is granted.")
return null
}
val fileFormat = ".${cachedFile.name.substringAfterLast(".")}".lowercase().trim()
return when (fileFormat) {
".pdf" -> {
pdfCoverParser.parse(cachedFile)
}
".epub" -> {
epubCoverParser.parse(cachedFile)
}
".txt" -> {
txtCoverParser.parse(cachedFile)
}
".fb2" -> {
fb2CoverParser.parse(cachedFile)
}
".html" -> {
htmlCoverParser.parse(cachedFile)
}
".htm" -> {
htmlCoverParser.parse(cachedFile)
}
".md" -> {
txtCoverParser.parse(cachedFile)
}
else -> {
logE("Wrong file format, could not find supported extension.")
null
}
}
}
}

View file

@ -0,0 +1,83 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.cover
import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.core.log.logE
import ua.acclorite.book_story.data.model.file.CachedFile
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile
import javax.inject.Inject
class EpubCoverParser @Inject constructor() : CoverParser {
override suspend fun parse(cachedFile: CachedFile): CoverImage? {
return try {
var coverImage: CoverImage? = null
val rawFile = cachedFile.rawFile
if (rawFile == null || !rawFile.exists() || !rawFile.canRead()) return null
withContext(Dispatchers.IO) {
ZipFile(rawFile).use { zip ->
val opfEntry = zip.entries().asSequence().find { entry ->
entry.name.endsWith(".opf", ignoreCase = true)
} ?: return@withContext
val opfContent = zip
.getInputStream(opfEntry)
.bufferedReader()
.use { it.readText() }
val document = Jsoup.parse(opfContent, Parser.xmlParser())
val coverImagePath = document
.select("metadata > meta[name=cover]")
.attr("content")
.let { coverId ->
if (coverId.isNotBlank()) {
document
.select("manifest > item[id=$coverId]")
.attr("href")
.also { src ->
if (src.isBlank()) return@also
return@let src
}
}
document
.select("manifest > item[media-type*=image]")
.firstOrNull()
?.attr("href")
?.also { src -> if (src.isBlank()) return@let null }
}
?.let { src -> URLDecoder.decode(src, StandardCharsets.UTF_8.name()) }
?: return@use
zip.entries().asSequence().first { entry ->
if (entry.name.endsWith(coverImagePath)) {
val imageBytes = zip.getInputStream(entry).readBytes()
coverImage = BitmapFactory.decodeByteArray(
imageBytes, 0, imageBytes.size
)
return@first true
} else return@first false
}
}
}
coverImage
} catch (_: Exception) {
logE("Could not parse cover image.")
null
}
}
}

View file

@ -0,0 +1,18 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.cover
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.data.model.file.CachedFile
import javax.inject.Inject
class Fb2CoverParser @Inject constructor() : CoverParser {
override suspend fun parse(cachedFile: CachedFile): CoverImage? {
return null
}
}

View file

@ -0,0 +1,18 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.cover
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.data.model.file.CachedFile
import javax.inject.Inject
class HtmlCoverParser @Inject constructor() : CoverParser {
override suspend fun parse(cachedFile: CachedFile): CoverImage? {
return null
}
}

View file

@ -0,0 +1,18 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.cover
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.data.model.file.CachedFile
import javax.inject.Inject
class PdfCoverParser @Inject constructor() : CoverParser {
override suspend fun parse(cachedFile: CachedFile): CoverImage? {
return null
}
}

View file

@ -0,0 +1,18 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.cover
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.data.model.file.CachedFile
import javax.inject.Inject
class TxtCoverParser @Inject constructor() : CoverParser {
override suspend fun parse(cachedFile: CachedFile): CoverImage? {
return null
}
}

View file

@ -6,28 +6,22 @@
package ua.acclorite.book_story.data.parser.file package ua.acclorite.book_story.data.parser.file
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.core.ui.UIText import ua.acclorite.book_story.core.ui.UIText
import ua.acclorite.book_story.data.model.common.BookWithCover
import ua.acclorite.book_story.data.model.file.CachedFile import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import java.io.File
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.zip.ZipFile import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject
class EpubFileParser @Inject constructor() : FileParser { class EpubFileParser @Inject constructor() : FileParser {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? { override suspend fun parse(cachedFile: CachedFile): Book? {
return try { return try {
var book: BookWithCover? = null var book: Book? = null
val rawFile = cachedFile.rawFile val rawFile = cachedFile.rawFile
if (rawFile == null || !rawFile.exists() || !rawFile.canRead()) return null if (rawFile == null || !rawFile.exists() || !rawFile.canRead()) return null
@ -66,42 +60,17 @@ class EpubFileParser @Inject constructor() : FileParser {
} }
} }
val coverImage = document book = Book(
.select("metadata > meta[name=cover]") title = title,
.attr("content") author = author,
.let { coverId -> description = description,
if (coverId.isNotBlank()) { scrollIndex = 0,
document scrollOffset = 0,
.select("manifest > item[id=$coverId]") progress = 0f,
.attr("href") filePath = cachedFile.path,
.also { src -> lastOpened = null,
if (src.isBlank()) return@also categories = emptyList(),
return@let src coverImage = null
}
}
document
.select("manifest > item[media-type*=image]")
.firstOrNull()
?.attr("href")
?.also { src -> if (src.isBlank()) return@let null }
}
?.let { src -> URLDecoder.decode(src, StandardCharsets.UTF_8.name()) }
book = BookWithCover(
book = Book(
title = title,
author = author,
description = description,
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = cachedFile.path,
lastOpened = null,
categories = emptyList(),
coverImage = null
),
coverImage = extractCoverImageBitmap(rawFile, coverImage)
) )
} }
} }
@ -111,21 +80,4 @@ class EpubFileParser @Inject constructor() : FileParser {
null null
} }
} }
private fun extractCoverImageBitmap(file: File, coverImagePath: String?): Bitmap? {
if (coverImagePath.isNullOrBlank()) {
return null
}
ZipFile(file).use { zip ->
zip.entries().asSequence().forEach { entry ->
if (entry.name.endsWith(coverImagePath)) {
val imageBytes = zip.getInputStream(entry).readBytes()
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
}
}
}
return null
}
} }

View file

@ -10,14 +10,13 @@ import org.jsoup.Jsoup
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.core.ui.UIText import ua.acclorite.book_story.core.ui.UIText
import ua.acclorite.book_story.data.model.common.BookWithCover
import ua.acclorite.book_story.data.model.file.CachedFile import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import javax.inject.Inject import javax.inject.Inject
class Fb2FileParser @Inject constructor() : FileParser { class Fb2FileParser @Inject constructor() : FileParser {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? { override suspend fun parse(cachedFile: CachedFile): Book? {
return try { return try {
val document = cachedFile.openInputStream()?.use { val document = cachedFile.openInputStream()?.use {
Jsoup.parse(it, null, "", Parser.xmlParser()) Jsoup.parse(it, null, "", Parser.xmlParser())
@ -44,19 +43,16 @@ class Fb2FileParser @Inject constructor() : FileParser {
this this
} }
BookWithCover( Book(
book = Book( title = title,
title = title, author = author,
author = author, description = description,
description = description, scrollIndex = 0,
scrollIndex = 0, scrollOffset = 0,
scrollOffset = 0, progress = 0f,
progress = 0f, filePath = cachedFile.path,
filePath = cachedFile.path, lastOpened = null,
lastOpened = null, categories = emptyList(),
categories = emptyList(),
coverImage = null
),
coverImage = null coverImage = null
) )
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -6,9 +6,9 @@
package ua.acclorite.book_story.data.parser.file package ua.acclorite.book_story.data.parser.file
import ua.acclorite.book_story.data.model.common.BookWithCover
import ua.acclorite.book_story.data.model.file.CachedFile import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.domain.model.library.Book
interface FileParser { interface FileParser {
suspend fun parse(cachedFile: CachedFile): BookWithCover? suspend fun parse(cachedFile: CachedFile): Book?
} }

View file

@ -7,8 +7,8 @@
package ua.acclorite.book_story.data.parser.file package ua.acclorite.book_story.data.parser.file
import android.util.Log import android.util.Log
import ua.acclorite.book_story.data.model.common.BookWithCover
import ua.acclorite.book_story.data.model.file.CachedFile import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.domain.model.library.Book
import javax.inject.Inject import javax.inject.Inject
private const val FILE_PARSER = "File Parser" private const val FILE_PARSER = "File Parser"
@ -21,7 +21,7 @@ class FileParserImpl @Inject constructor(
private val htmlFileParser: HtmlFileParser, private val htmlFileParser: HtmlFileParser,
) : FileParser { ) : FileParser {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? { override suspend fun parse(cachedFile: CachedFile): Book? {
if (!cachedFile.canAccess()) { if (!cachedFile.canAccess()) {
Log.e(FILE_PARSER, "File does not exist or no read access is granted.") Log.e(FILE_PARSER, "File does not exist or no read access is granted.")
return null return null

View file

@ -10,14 +10,13 @@ import org.jsoup.Jsoup
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.core.ui.UIText import ua.acclorite.book_story.core.ui.UIText
import ua.acclorite.book_story.data.model.common.BookWithCover
import ua.acclorite.book_story.data.model.file.CachedFile import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import javax.inject.Inject import javax.inject.Inject
class HtmlFileParser @Inject constructor() : FileParser { class HtmlFileParser @Inject constructor() : FileParser {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? { override suspend fun parse(cachedFile: CachedFile): Book? {
return try { return try {
val document = cachedFile.openInputStream()?.use { val document = cachedFile.openInputStream()?.use {
Jsoup.parse(it, null, "", Parser.htmlParser()) Jsoup.parse(it, null, "", Parser.htmlParser())
@ -30,19 +29,16 @@ class HtmlFileParser @Inject constructor() : FileParser {
return@run this return@run this
} }
BookWithCover( Book(
book = Book( title = title,
title = title, author = UIText.StringResource(R.string.unknown_author),
author = UIText.StringResource(R.string.unknown_author), description = null,
description = null, scrollIndex = 0,
scrollIndex = 0, scrollOffset = 0,
scrollOffset = 0, progress = 0f,
progress = 0f, filePath = cachedFile.path,
filePath = cachedFile.path, lastOpened = null,
lastOpened = null, categories = emptyList(),
categories = emptyList(),
coverImage = null
),
coverImage = null coverImage = null
) )
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -11,7 +11,6 @@ import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import com.tom_roush.pdfbox.pdmodel.PDDocument import com.tom_roush.pdfbox.pdmodel.PDDocument
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.core.ui.UIText import ua.acclorite.book_story.core.ui.UIText
import ua.acclorite.book_story.data.model.common.BookWithCover
import ua.acclorite.book_story.data.model.file.CachedFile import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import javax.inject.Inject import javax.inject.Inject
@ -20,7 +19,7 @@ class PdfFileParser @Inject constructor(
private val application: Application private val application: Application
) : FileParser { ) : FileParser {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? { override suspend fun parse(cachedFile: CachedFile): Book? {
return try { return try {
PDFBoxResourceLoader.init(application) PDFBoxResourceLoader.init(application)
val document = PDDocument.load(cachedFile.openInputStream()) val document = PDDocument.load(cachedFile.openInputStream())
@ -35,19 +34,16 @@ class PdfFileParser @Inject constructor(
document.close() document.close()
BookWithCover( Book(
book = Book( title = title,
title = title, author = author,
author = author, description = description,
description = description, scrollIndex = 0,
scrollIndex = 0, scrollOffset = 0,
scrollOffset = 0, progress = 0f,
progress = 0f, filePath = cachedFile.path,
filePath = cachedFile.path, lastOpened = null,
lastOpened = null, categories = emptyList(),
categories = emptyList(),
coverImage = null
),
coverImage = null coverImage = null
) )
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -8,31 +8,27 @@ package ua.acclorite.book_story.data.parser.file
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.core.ui.UIText import ua.acclorite.book_story.core.ui.UIText
import ua.acclorite.book_story.data.model.common.BookWithCover
import ua.acclorite.book_story.data.model.file.CachedFile import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
import javax.inject.Inject import javax.inject.Inject
class TxtFileParser @Inject constructor() : FileParser { class TxtFileParser @Inject constructor() : FileParser {
override suspend fun parse(cachedFile: CachedFile): BookWithCover? { override suspend fun parse(cachedFile: CachedFile): Book? {
return try { return try {
val title = cachedFile.name.substringBeforeLast(".").trim() val title = cachedFile.name.substringBeforeLast(".").trim()
val author = UIText.StringResource(R.string.unknown_author) val author = UIText.StringResource(R.string.unknown_author)
BookWithCover( Book(
book = Book( title = title,
title = title, author = author,
author = author, description = null,
description = null, scrollIndex = 0,
scrollIndex = 0, scrollOffset = 0,
scrollOffset = 0, progress = 0f,
progress = 0f, filePath = cachedFile.path,
filePath = cachedFile.path, lastOpened = null,
lastOpened = null, categories = emptyList(),
categories = emptyList(),
coverImage = null
),
coverImage = null coverImage = null
) )
} catch (e: Exception) { } catch (e: Exception) {

View file

@ -12,7 +12,7 @@ import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.data.local.room.BookDatabase import ua.acclorite.book_story.data.local.room.BookDatabase
import ua.acclorite.book_story.data.mapper.book.BookMapper import ua.acclorite.book_story.data.mapper.book.BookMapper
import ua.acclorite.book_story.data.mapper.file.FileMapper import ua.acclorite.book_story.data.mapper.file.FileMapper
import ua.acclorite.book_story.data.parser.file.FileParser import ua.acclorite.book_story.data.parser.cover.CoverParser
import ua.acclorite.book_story.data.parser.text.TextParser import ua.acclorite.book_story.data.parser.text.TextParser
import ua.acclorite.book_story.domain.model.file.File import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book import ua.acclorite.book_story.domain.model.library.Book
@ -27,7 +27,7 @@ class BookRepositoryImpl @Inject constructor(
private val database: BookDatabase, private val database: BookDatabase,
private val bookMapper: BookMapper, private val bookMapper: BookMapper,
private val fileMapper: FileMapper, private val fileMapper: FileMapper,
private val fileParser: FileParser, private val coverParser: CoverParser,
private val textParser: TextParser, private val textParser: TextParser,
private val fileProvider: FileProvider private val fileProvider: FileProvider
) : BookRepository { ) : BookRepository {
@ -87,8 +87,9 @@ class BookRepositoryImpl @Inject constructor(
override suspend fun getDefaultCover(book: Book): Result<CoverImage?> = runCatching { override suspend fun getDefaultCover(book: Book): Result<CoverImage?> = runCatching {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
fileProvider.getFileFromBook(book) fileProvider.getFileFromBook(book).mapCatching {
.mapCatching { fileParser.parse(it)?.coverImage } coverParser.parse(it)
}
} }
} }
} }

View file

@ -8,13 +8,15 @@ package ua.acclorite.book_story.data.repository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.core.data.ExtensionsData import ua.acclorite.book_story.core.data.ExtensionsData
import ua.acclorite.book_story.data.local.room.BookDatabase import ua.acclorite.book_story.data.local.room.BookDatabase
import ua.acclorite.book_story.data.mapper.file.FileMapper import ua.acclorite.book_story.data.mapper.file.FileMapper
import ua.acclorite.book_story.data.model.common.BookWithCover
import ua.acclorite.book_story.data.model.file.CachedFile import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.data.parser.cover.CoverParser
import ua.acclorite.book_story.data.parser.file.FileParser import ua.acclorite.book_story.data.parser.file.FileParser
import ua.acclorite.book_story.domain.model.file.File import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book
import ua.acclorite.book_story.domain.repository.FileSystemRepository import ua.acclorite.book_story.domain.repository.FileSystemRepository
import ua.acclorite.book_story.domain.service.FileProvider import ua.acclorite.book_story.domain.service.FileProvider
import javax.inject.Inject import javax.inject.Inject
@ -25,6 +27,7 @@ class FileSystemRepositoryImpl @Inject constructor(
private val database: BookDatabase, private val database: BookDatabase,
private val fileMapper: FileMapper, private val fileMapper: FileMapper,
private val fileParser: FileParser, private val fileParser: FileParser,
private val coverParser: CoverParser,
private val fileProvider: FileProvider private val fileProvider: FileProvider
) : FileSystemRepository { ) : FileSystemRepository {
@ -73,10 +76,17 @@ class FileSystemRepositoryImpl @Inject constructor(
return files return files
} }
override suspend fun getBookFromFile(file: File): Result<BookWithCover> = runCatching { override suspend fun getBookFromFile(file: File): Result<Pair<Book, CoverImage?>> =
withContext(Dispatchers.IO) { runCatching {
fileParser.parse(fileMapper.toCachedFile(file)) withContext(Dispatchers.IO) {
?: throw Exception("Could not parse ${file.name}.") val cachedFile = fileMapper.toCachedFile(file)
val book = fileParser.parse(
cachedFile = cachedFile
) ?: throw Exception("Could not parse ${file.name}.")
val coverImage = coverParser.parse(cachedFile = cachedFile)
return@withContext book to coverImage
}
} }
}
} }

View file

@ -6,8 +6,9 @@
package ua.acclorite.book_story.domain.repository package ua.acclorite.book_story.domain.repository
import ua.acclorite.book_story.data.model.common.BookWithCover import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.domain.model.file.File import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book
interface FileSystemRepository { interface FileSystemRepository {
suspend fun searchFiles( suspend fun searchFiles(
@ -16,5 +17,5 @@ interface FileSystemRepository {
suspend fun getBookFromFile( suspend fun getBookFromFile(
file: File file: File
): Result<BookWithCover> ): Result<Pair<Book, CoverImage?>>
} }

View file

@ -24,8 +24,8 @@ class AddBookUseCase @Inject constructor(
suspend operator fun invoke(book: Book, coverImage: CoverImage?) { suspend operator fun invoke(book: Book, coverImage: CoverImage?) {
logI("Inserting [${book.title}].") logI("Inserting [${book.title}].")
val coverImageUri = coverImage?.let { val coverImageUri = coverImage?.let { coverImage ->
coverImageHandler.saveCover(it).fold( coverImageHandler.saveCover(coverImage).fold(
onSuccess = { onSuccess = {
logI("Successfully saved cover image of [${book.title}].") logI("Successfully saved cover image of [${book.title}].")
it.toUri() it.toUri()

View file

@ -26,9 +26,7 @@ class CanResetCoverImageUseCase @Inject constructor(
bookRepository.getBook(bookId).mapCatching { book -> bookRepository.getBook(bookId).mapCatching { book ->
// Getting default cover image // Getting default cover image
val defaultCoverImage = bookRepository.getDefaultCover(book).getOrThrow() val defaultCoverImage = bookRepository.getDefaultCover(book).getOrThrow()
if (defaultCoverImage == null) { ?: return@mapCatching false
return@mapCatching false
}
// Return true if current cover is null (and default is not) // Return true if current cover is null (and default is not)
if (book.coverImage == null) { if (book.coverImage == null) {

View file

@ -25,9 +25,7 @@ class ResetCoverImageUseCase @Inject constructor(
bookRepository.getBook(bookId).mapCatching { book -> bookRepository.getBook(bookId).mapCatching { book ->
// Getting default cover image // Getting default cover image
val defaultCoverImage = bookRepository.getDefaultCover(book).getOrThrow() val defaultCoverImage = bookRepository.getDefaultCover(book).getOrThrow()
if (defaultCoverImage == null) { ?: throw NoSuchElementException("Could not find default cover image")
throw NoSuchElementException("Could not find default cover image")
}
// Deleting old cover // Deleting old cover
book.coverImage?.let { coverImageHandler.deleteCover(it) }?.onFailure { book.coverImage?.let { coverImageHandler.deleteCover(it) }?.onFailure {

View file

@ -6,14 +6,11 @@
package ua.acclorite.book_story.domain.use_case.file_system package ua.acclorite.book_story.domain.use_case.file_system
import ua.acclorite.book_story.R import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.core.log.logE import ua.acclorite.book_story.core.log.logE
import ua.acclorite.book_story.core.log.logI import ua.acclorite.book_story.core.log.logI
import ua.acclorite.book_story.core.ui.UIText
import ua.acclorite.book_story.data.model.common.NullableBook
import ua.acclorite.book_story.data.model.common.NullableBook.NotNull
import ua.acclorite.book_story.data.model.common.NullableBook.Null
import ua.acclorite.book_story.domain.model.file.File import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.model.library.Book
import ua.acclorite.book_story.domain.repository.FileSystemRepository import ua.acclorite.book_story.domain.repository.FileSystemRepository
import javax.inject.Inject import javax.inject.Inject
@ -21,20 +18,17 @@ class GetBookFromFileUseCase @Inject constructor(
private val fileSystemRepository: FileSystemRepository private val fileSystemRepository: FileSystemRepository
) { ) {
suspend operator fun invoke(file: File): NullableBook { suspend operator fun invoke(file: File): Pair<Book, CoverImage?>? {
logI("Getting book from [${file.name}] file.") logI("Getting book from [${file.name}] file.")
return fileSystemRepository.getBookFromFile(file).fold( return fileSystemRepository.getBookFromFile(file).fold(
onSuccess = { onSuccess = {
logI("Successfully got [${it.book.title}] book from file.") logI("Successfully got [${it.first.title}] book from file.")
NotNull(it) it
}, },
onFailure = { onFailure = {
logE("Could not get book from file with error: ${it.message}") logE("Could not get book from file with error: ${it.message}")
Null( null
file.name,
UIText.StringResource(R.string.error_something_went_wrong)
)
} }
) )
} }

View file

@ -8,7 +8,7 @@ package ua.acclorite.book_story.presentation.browse
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.presentation.browse.model.SelectableFile import ua.acclorite.book_story.presentation.browse.model.SelectableFile
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook import ua.acclorite.book_story.presentation.browse.model.SelectableNullableBook
@Immutable @Immutable
sealed class BrowseEvent { sealed class BrowseEvent {

View file

@ -24,14 +24,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ua.acclorite.book_story.data.model.common.NullableBook import ua.acclorite.book_story.R
import ua.acclorite.book_story.core.ui.UIText
import ua.acclorite.book_story.domain.model.file.File import ua.acclorite.book_story.domain.model.file.File
import ua.acclorite.book_story.domain.use_case.book.AddBookUseCase import ua.acclorite.book_story.domain.use_case.book.AddBookUseCase
import ua.acclorite.book_story.domain.use_case.file_system.GetBookFromFileUseCase import ua.acclorite.book_story.domain.use_case.file_system.GetBookFromFileUseCase
import ua.acclorite.book_story.domain.use_case.file_system.GetFilesUseCase import ua.acclorite.book_story.domain.use_case.file_system.GetFilesUseCase
import ua.acclorite.book_story.presentation.browse.model.NullableBook
import ua.acclorite.book_story.presentation.browse.model.SelectableFile import ua.acclorite.book_story.presentation.browse.model.SelectableFile
import ua.acclorite.book_story.presentation.browse.model.SelectableNullableBook
import ua.acclorite.book_story.presentation.library.LibraryScreen import ua.acclorite.book_story.presentation.library.LibraryScreen
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook
import javax.inject.Inject import javax.inject.Inject
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@ -248,17 +250,29 @@ class BrowseModel @Inject constructor(
val books = _state.value.files.mapNotNull { file -> val books = _state.value.files.mapNotNull { file ->
ensureActive() ensureActive()
if (!file.selected) return@mapNotNull null if (!file.selected) return@mapNotNull null
SelectableNullableBook(
data = getBookFromFileUseCase( val nullableBook = getBookFromFileUseCase(
File( File(
name = file.name, name = file.name,
uri = file.uri.toString(), uri = file.uri.toString(),
path = file.path, path = file.path,
size = file.size, size = file.size,
lastModified = file.lastModified, lastModified = file.lastModified,
isDirectory = file.isDirectory isDirectory = file.isDirectory
)
).let {
if (it != null) {
NullableBook.NotNull(it.first, it.second)
} else {
NullableBook.Null(
fileName = file.name,
UIText.StringResource(resId = R.string.error_something_went_wrong)
) )
), }
}
SelectableNullableBook(
data = nullableBook,
selected = true selected = true
) )
} }
@ -294,12 +308,12 @@ class BrowseModel @Inject constructor(
is BrowseEvent.OnActionAddDialog -> { is BrowseEvent.OnActionAddDialog -> {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
_state.value.selectedBooksAddDialog.mapNotNull { _state.value.selectedBooksAddDialog.mapNotNull {
if (it.data is NullableBook.NotNull && it.selected) return@mapNotNull it.data.bookWithCover if (it.data is NullableBook.NotNull && it.selected) return@mapNotNull it.data
return@mapNotNull null return@mapNotNull null
}.ifEmpty { return@withContext }.forEach { bookWithCover -> }.ifEmpty { return@withContext }.forEach { nullableBook ->
addBookUseCase( addBookUseCase(
bookWithCover.book, nullableBook.book,
bookWithCover.coverImage nullableBook.coverImage
) )
} }

View file

@ -10,7 +10,7 @@ import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.core.BottomSheet import ua.acclorite.book_story.core.BottomSheet
import ua.acclorite.book_story.core.Dialog import ua.acclorite.book_story.core.Dialog
import ua.acclorite.book_story.presentation.browse.model.SelectableFile import ua.acclorite.book_story.presentation.browse.model.SelectableFile
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook import ua.acclorite.book_story.presentation.browse.model.SelectableNullableBook
@Immutable @Immutable
data class BrowseState( data class BrowseState(

View file

@ -0,0 +1,25 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2025 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.presentation.browse.model
import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.core.CoverImage
import ua.acclorite.book_story.core.ui.UIText
import ua.acclorite.book_story.domain.model.library.Book
@Immutable
sealed class NullableBook {
data class NotNull(
val book: Book,
val coverImage: CoverImage?
) : NullableBook()
data class Null(
val fileName: String,
val message: UIText?
) : NullableBook()
}

View file

@ -4,10 +4,9 @@
* SPDX-License-Identifier: GPL-3.0-only * SPDX-License-Identifier: GPL-3.0-only
*/ */
package ua.acclorite.book_story.presentation.library.model package ua.acclorite.book_story.presentation.browse.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import ua.acclorite.book_story.data.model.common.NullableBook
@Immutable @Immutable
data class SelectableNullableBook( data class SelectableNullableBook(

View file

@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddChart import androidx.compose.material.icons.filled.AddChart
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -20,13 +20,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.model.common.NullableBook
import ua.acclorite.book_story.presentation.browse.BrowseEvent import ua.acclorite.book_story.presentation.browse.BrowseEvent
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook import ua.acclorite.book_story.presentation.browse.model.NullableBook
import ua.acclorite.book_story.presentation.browse.model.SelectableNullableBook
import ua.acclorite.book_story.ui.common.components.dialog.Dialog import ua.acclorite.book_story.ui.common.components.dialog.Dialog
import ua.acclorite.book_story.ui.common.components.progress_indicator.CircularProgressIndicator import ua.acclorite.book_story.ui.common.components.progress_indicator.CircularProgressIndicator
import ua.acclorite.book_story.ui.common.helpers.showToast import ua.acclorite.book_story.ui.common.helpers.showToast
import java.util.UUID
@Composable @Composable
fun BrowseAddDialog( fun BrowseAddDialog(
@ -63,22 +62,25 @@ fun BrowseAddDialog(
} }
} }
} else { } else {
items( itemsIndexed(
selectedBooksAddDialog, selectedBooksAddDialog,
key = { it.data.fileName ?: UUID.randomUUID() } key = { index, _ -> index }
) { book -> ) { _, book ->
BrowseAddDialogItem( BrowseAddDialogItem(
result = book result = book
) { ) {
if (it) { when (book.data) {
selectAddDialog( is NullableBook.NotNull -> {
BrowseEvent.OnSelectAddDialog( selectAddDialog(
book = book BrowseEvent.OnSelectAddDialog(
book = book
)
) )
) }
} else {
book.data.message?.asString(context) is NullableBook.Null -> {
?.showToast(context = context) book.data.message?.asString(context)?.showToast(context = context)
}
} }
} }
} }

View file

@ -24,73 +24,77 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ua.acclorite.book_story.R import ua.acclorite.book_story.R
import ua.acclorite.book_story.data.model.common.NullableBook import ua.acclorite.book_story.presentation.browse.model.NullableBook
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook import ua.acclorite.book_story.presentation.browse.model.SelectableNullableBook
import ua.acclorite.book_story.ui.common.components.common.CircularCheckbox import ua.acclorite.book_story.ui.common.components.common.CircularCheckbox
import ua.acclorite.book_story.ui.common.components.common.StyledText import ua.acclorite.book_story.ui.common.components.common.StyledText
@Composable @Composable
fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: (Boolean) -> Unit) { fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: () -> Unit) {
if (result.data is NullableBook.NotNull) { when (result.data) {
Row( is NullableBook.NotNull -> {
Modifier Row(
.fillMaxWidth() Modifier
.clickable { .fillMaxWidth()
onClick(true) .clickable {
} onClick()
.padding(vertical = 12.dp, horizontal = 24.dp), }
verticalAlignment = Alignment.CenterVertically .padding(vertical = 12.dp, horizontal = 24.dp),
) { verticalAlignment = Alignment.CenterVertically
Column(
Modifier.weight(1f)
) { ) {
StyledText( Column(
text = result.data.bookWithCover!!.book.title, Modifier.weight(1f)
style = MaterialTheme.typography.bodyLarge.copy( ) {
color = MaterialTheme.colorScheme.onSurface StyledText(
), text = result.data.book.title,
maxLines = 1 style = MaterialTheme.typography.bodyLarge.copy(
) color = MaterialTheme.colorScheme.onSurface
StyledText( ),
text = result.data.bookWithCover.book.author.asString(), maxLines = 1
style = MaterialTheme.typography.bodyMedium.copy( )
color = MaterialTheme.colorScheme.onSurfaceVariant StyledText(
), text = result.data.book.author.asString(),
maxLines = 1 style = MaterialTheme.typography.bodyMedium.copy(
) color = MaterialTheme.colorScheme.onSurfaceVariant
} ),
Row { maxLines = 1
Spacer(modifier = Modifier.width(24.dp)) )
CircularCheckbox( }
selected = result.selected, Row {
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh Spacer(modifier = Modifier.width(24.dp))
) CircularCheckbox(
selected = result.selected,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
}
} }
} }
} else {
Row( is NullableBook.Null -> {
Modifier Row(
.fillMaxWidth() Modifier
.clickable { .fillMaxWidth()
onClick(false) .clickable {
} onClick()
.padding(vertical = 12.dp, horizontal = 24.dp), }
verticalAlignment = Alignment.CenterVertically .padding(vertical = 12.dp, horizontal = 24.dp),
) { verticalAlignment = Alignment.CenterVertically
Icon( ) {
imageVector = Icons.Default.Error, Icon(
contentDescription = stringResource(id = R.string.error_content_desc), imageVector = Icons.Default.Error,
modifier = Modifier.size(26.dp), contentDescription = stringResource(id = R.string.error_content_desc),
tint = MaterialTheme.colorScheme.error modifier = Modifier.size(26.dp),
) tint = MaterialTheme.colorScheme.error
Spacer(modifier = Modifier.width(8.dp)) )
StyledText( Spacer(modifier = Modifier.width(8.dp))
text = result.data.fileName!!, StyledText(
style = MaterialTheme.typography.bodyLarge.copy( text = result.data.fileName,
color = MaterialTheme.colorScheme.error style = MaterialTheme.typography.bodyLarge.copy(
), color = MaterialTheme.colorScheme.error
maxLines = 2 ),
) maxLines = 2
)
}
} }
} }
} }

View file

@ -17,7 +17,7 @@ import ua.acclorite.book_story.core.Dialog
import ua.acclorite.book_story.presentation.browse.BrowseEvent import ua.acclorite.book_story.presentation.browse.BrowseEvent
import ua.acclorite.book_story.presentation.browse.model.BrowseLayout import ua.acclorite.book_story.presentation.browse.model.BrowseLayout
import ua.acclorite.book_story.presentation.browse.model.SelectableFile import ua.acclorite.book_story.presentation.browse.model.SelectableFile
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook import ua.acclorite.book_story.presentation.browse.model.SelectableNullableBook
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable

View file

@ -10,7 +10,7 @@ import androidx.compose.runtime.Composable
import ua.acclorite.book_story.core.Dialog import ua.acclorite.book_story.core.Dialog
import ua.acclorite.book_story.presentation.browse.BrowseEvent import ua.acclorite.book_story.presentation.browse.BrowseEvent
import ua.acclorite.book_story.presentation.browse.BrowseScreen import ua.acclorite.book_story.presentation.browse.BrowseScreen
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook import ua.acclorite.book_story.presentation.browse.model.SelectableNullableBook
@Composable @Composable
fun BrowseDialog( fun BrowseDialog(