refactor: cover parser
This commit is contained in:
parent
c76f7f38d5
commit
9e17d86588
33 changed files with 477 additions and 305 deletions
|
|
@ -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.history.HistoryMapper
|
||||
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.FileParserImpl
|
||||
import ua.acclorite.book_story.data.parser.text.TextParser
|
||||
|
|
@ -121,6 +123,12 @@ abstract class RepositoryModule {
|
|||
fileParserImpl: FileParserImpl
|
||||
): FileParser
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCoverParser(
|
||||
coverParserImpl: CoverParserImpl
|
||||
): CoverParser
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTextParser(
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -6,28 +6,22 @@
|
|||
|
||||
package ua.acclorite.book_story.data.parser.file
|
||||
|
||||
import android.graphics.Bitmap
|
||||
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.R
|
||||
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.domain.model.library.Book
|
||||
import java.io.File
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.zip.ZipFile
|
||||
import javax.inject.Inject
|
||||
|
||||
class EpubFileParser @Inject constructor() : FileParser {
|
||||
|
||||
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
|
||||
override suspend fun parse(cachedFile: CachedFile): Book? {
|
||||
return try {
|
||||
var book: BookWithCover? = null
|
||||
var book: Book? = null
|
||||
|
||||
val rawFile = cachedFile.rawFile
|
||||
if (rawFile == null || !rawFile.exists() || !rawFile.canRead()) return null
|
||||
|
|
@ -66,29 +60,6 @@ class EpubFileParser @Inject constructor() : FileParser {
|
|||
}
|
||||
}
|
||||
|
||||
val coverImage = 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()) }
|
||||
|
||||
book = BookWithCover(
|
||||
book = Book(
|
||||
title = title,
|
||||
author = author,
|
||||
|
|
@ -100,8 +71,6 @@ class EpubFileParser @Inject constructor() : FileParser {
|
|||
lastOpened = null,
|
||||
categories = emptyList(),
|
||||
coverImage = null
|
||||
),
|
||||
coverImage = extractCoverImageBitmap(rawFile, coverImage)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -111,21 +80,4 @@ class EpubFileParser @Inject constructor() : FileParser {
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -10,14 +10,13 @@ import org.jsoup.Jsoup
|
|||
import org.jsoup.parser.Parser
|
||||
import ua.acclorite.book_story.R
|
||||
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.domain.model.library.Book
|
||||
import javax.inject.Inject
|
||||
|
||||
class Fb2FileParser @Inject constructor() : FileParser {
|
||||
|
||||
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
|
||||
override suspend fun parse(cachedFile: CachedFile): Book? {
|
||||
return try {
|
||||
val document = cachedFile.openInputStream()?.use {
|
||||
Jsoup.parse(it, null, "", Parser.xmlParser())
|
||||
|
|
@ -44,8 +43,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
|
|||
this
|
||||
}
|
||||
|
||||
BookWithCover(
|
||||
book = Book(
|
||||
Book(
|
||||
title = title,
|
||||
author = author,
|
||||
description = description,
|
||||
|
|
@ -56,8 +54,6 @@ class Fb2FileParser @Inject constructor() : FileParser {
|
|||
lastOpened = null,
|
||||
categories = emptyList(),
|
||||
coverImage = null
|
||||
),
|
||||
coverImage = null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
|
||||
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.domain.model.library.Book
|
||||
|
||||
interface FileParser {
|
||||
suspend fun parse(cachedFile: CachedFile): BookWithCover?
|
||||
suspend fun parse(cachedFile: CachedFile): Book?
|
||||
}
|
||||
|
|
@ -7,8 +7,8 @@
|
|||
package ua.acclorite.book_story.data.parser.file
|
||||
|
||||
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.domain.model.library.Book
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val FILE_PARSER = "File Parser"
|
||||
|
|
@ -21,7 +21,7 @@ class FileParserImpl @Inject constructor(
|
|||
private val htmlFileParser: HtmlFileParser,
|
||||
) : FileParser {
|
||||
|
||||
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
|
||||
override suspend fun parse(cachedFile: CachedFile): Book? {
|
||||
if (!cachedFile.canAccess()) {
|
||||
Log.e(FILE_PARSER, "File does not exist or no read access is granted.")
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -10,14 +10,13 @@ import org.jsoup.Jsoup
|
|||
import org.jsoup.parser.Parser
|
||||
import ua.acclorite.book_story.R
|
||||
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.domain.model.library.Book
|
||||
import javax.inject.Inject
|
||||
|
||||
class HtmlFileParser @Inject constructor() : FileParser {
|
||||
|
||||
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
|
||||
override suspend fun parse(cachedFile: CachedFile): Book? {
|
||||
return try {
|
||||
val document = cachedFile.openInputStream()?.use {
|
||||
Jsoup.parse(it, null, "", Parser.htmlParser())
|
||||
|
|
@ -30,8 +29,7 @@ class HtmlFileParser @Inject constructor() : FileParser {
|
|||
return@run this
|
||||
}
|
||||
|
||||
BookWithCover(
|
||||
book = Book(
|
||||
Book(
|
||||
title = title,
|
||||
author = UIText.StringResource(R.string.unknown_author),
|
||||
description = null,
|
||||
|
|
@ -42,8 +40,6 @@ class HtmlFileParser @Inject constructor() : FileParser {
|
|||
lastOpened = null,
|
||||
categories = emptyList(),
|
||||
coverImage = null
|
||||
),
|
||||
coverImage = null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
|
|||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||
import ua.acclorite.book_story.R
|
||||
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.domain.model.library.Book
|
||||
import javax.inject.Inject
|
||||
|
|
@ -20,7 +19,7 @@ class PdfFileParser @Inject constructor(
|
|||
private val application: Application
|
||||
) : FileParser {
|
||||
|
||||
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
|
||||
override suspend fun parse(cachedFile: CachedFile): Book? {
|
||||
return try {
|
||||
PDFBoxResourceLoader.init(application)
|
||||
val document = PDDocument.load(cachedFile.openInputStream())
|
||||
|
|
@ -35,8 +34,7 @@ class PdfFileParser @Inject constructor(
|
|||
|
||||
document.close()
|
||||
|
||||
BookWithCover(
|
||||
book = Book(
|
||||
Book(
|
||||
title = title,
|
||||
author = author,
|
||||
description = description,
|
||||
|
|
@ -47,8 +45,6 @@ class PdfFileParser @Inject constructor(
|
|||
lastOpened = null,
|
||||
categories = emptyList(),
|
||||
coverImage = null
|
||||
),
|
||||
coverImage = null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
|
|
|||
|
|
@ -8,20 +8,18 @@ package ua.acclorite.book_story.data.parser.file
|
|||
|
||||
import ua.acclorite.book_story.R
|
||||
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.domain.model.library.Book
|
||||
import javax.inject.Inject
|
||||
|
||||
class TxtFileParser @Inject constructor() : FileParser {
|
||||
|
||||
override suspend fun parse(cachedFile: CachedFile): BookWithCover? {
|
||||
override suspend fun parse(cachedFile: CachedFile): Book? {
|
||||
return try {
|
||||
val title = cachedFile.name.substringBeforeLast(".").trim()
|
||||
val author = UIText.StringResource(R.string.unknown_author)
|
||||
|
||||
BookWithCover(
|
||||
book = Book(
|
||||
Book(
|
||||
title = title,
|
||||
author = author,
|
||||
description = null,
|
||||
|
|
@ -32,8 +30,6 @@ class TxtFileParser @Inject constructor() : FileParser {
|
|||
lastOpened = null,
|
||||
categories = emptyList(),
|
||||
coverImage = null
|
||||
),
|
||||
coverImage = null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
|
|
|||
|
|
@ -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.mapper.book.BookMapper
|
||||
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.domain.model.file.File
|
||||
import ua.acclorite.book_story.domain.model.library.Book
|
||||
|
|
@ -27,7 +27,7 @@ class BookRepositoryImpl @Inject constructor(
|
|||
private val database: BookDatabase,
|
||||
private val bookMapper: BookMapper,
|
||||
private val fileMapper: FileMapper,
|
||||
private val fileParser: FileParser,
|
||||
private val coverParser: CoverParser,
|
||||
private val textParser: TextParser,
|
||||
private val fileProvider: FileProvider
|
||||
) : BookRepository {
|
||||
|
|
@ -87,8 +87,9 @@ class BookRepositoryImpl @Inject constructor(
|
|||
|
||||
override suspend fun getDefaultCover(book: Book): Result<CoverImage?> = runCatching {
|
||||
return withContext(Dispatchers.IO) {
|
||||
fileProvider.getFileFromBook(book)
|
||||
.mapCatching { fileParser.parse(it)?.coverImage }
|
||||
fileProvider.getFileFromBook(book).mapCatching {
|
||||
coverParser.parse(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,13 +8,15 @@ package ua.acclorite.book_story.data.repository
|
|||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import ua.acclorite.book_story.core.CoverImage
|
||||
import ua.acclorite.book_story.core.data.ExtensionsData
|
||||
import ua.acclorite.book_story.data.local.room.BookDatabase
|
||||
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.parser.cover.CoverParser
|
||||
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.library.Book
|
||||
import ua.acclorite.book_story.domain.repository.FileSystemRepository
|
||||
import ua.acclorite.book_story.domain.service.FileProvider
|
||||
import javax.inject.Inject
|
||||
|
|
@ -25,6 +27,7 @@ class FileSystemRepositoryImpl @Inject constructor(
|
|||
private val database: BookDatabase,
|
||||
private val fileMapper: FileMapper,
|
||||
private val fileParser: FileParser,
|
||||
private val coverParser: CoverParser,
|
||||
private val fileProvider: FileProvider
|
||||
) : FileSystemRepository {
|
||||
|
||||
|
|
@ -73,10 +76,17 @@ class FileSystemRepositoryImpl @Inject constructor(
|
|||
return files
|
||||
}
|
||||
|
||||
override suspend fun getBookFromFile(file: File): Result<BookWithCover> = runCatching {
|
||||
override suspend fun getBookFromFile(file: File): Result<Pair<Book, CoverImage?>> =
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
fileParser.parse(fileMapper.toCachedFile(file))
|
||||
?: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,9 @@
|
|||
|
||||
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.library.Book
|
||||
|
||||
interface FileSystemRepository {
|
||||
suspend fun searchFiles(
|
||||
|
|
@ -16,5 +17,5 @@ interface FileSystemRepository {
|
|||
|
||||
suspend fun getBookFromFile(
|
||||
file: File
|
||||
): Result<BookWithCover>
|
||||
): Result<Pair<Book, CoverImage?>>
|
||||
}
|
||||
|
|
@ -24,8 +24,8 @@ class AddBookUseCase @Inject constructor(
|
|||
suspend operator fun invoke(book: Book, coverImage: CoverImage?) {
|
||||
logI("Inserting [${book.title}].")
|
||||
|
||||
val coverImageUri = coverImage?.let {
|
||||
coverImageHandler.saveCover(it).fold(
|
||||
val coverImageUri = coverImage?.let { coverImage ->
|
||||
coverImageHandler.saveCover(coverImage).fold(
|
||||
onSuccess = {
|
||||
logI("Successfully saved cover image of [${book.title}].")
|
||||
it.toUri()
|
||||
|
|
|
|||
|
|
@ -26,9 +26,7 @@ class CanResetCoverImageUseCase @Inject constructor(
|
|||
bookRepository.getBook(bookId).mapCatching { book ->
|
||||
// Getting default cover image
|
||||
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)
|
||||
if (book.coverImage == null) {
|
||||
|
|
|
|||
|
|
@ -25,9 +25,7 @@ class ResetCoverImageUseCase @Inject constructor(
|
|||
bookRepository.getBook(bookId).mapCatching { book ->
|
||||
// Getting default cover image
|
||||
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
|
||||
book.coverImage?.let { coverImageHandler.deleteCover(it) }?.onFailure {
|
||||
|
|
|
|||
|
|
@ -6,14 +6,11 @@
|
|||
|
||||
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.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.library.Book
|
||||
import ua.acclorite.book_story.domain.repository.FileSystemRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -21,20 +18,17 @@ class GetBookFromFileUseCase @Inject constructor(
|
|||
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.")
|
||||
|
||||
return fileSystemRepository.getBookFromFile(file).fold(
|
||||
onSuccess = {
|
||||
logI("Successfully got [${it.book.title}] book from file.")
|
||||
NotNull(it)
|
||||
logI("Successfully got [${it.first.title}] book from file.")
|
||||
it
|
||||
},
|
||||
onFailure = {
|
||||
logE("Could not get book from file with error: ${it.message}")
|
||||
Null(
|
||||
file.name,
|
||||
UIText.StringResource(R.string.error_something_went_wrong)
|
||||
)
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ package ua.acclorite.book_story.presentation.browse
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
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
|
||||
sealed class BrowseEvent {
|
||||
|
|
|
|||
|
|
@ -24,14 +24,16 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
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.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.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.SelectableNullableBook
|
||||
import ua.acclorite.book_story.presentation.library.LibraryScreen
|
||||
import ua.acclorite.book_story.presentation.library.model.SelectableNullableBook
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
|
|
@ -248,8 +250,8 @@ class BrowseModel @Inject constructor(
|
|||
val books = _state.value.files.mapNotNull { file ->
|
||||
ensureActive()
|
||||
if (!file.selected) return@mapNotNull null
|
||||
SelectableNullableBook(
|
||||
data = getBookFromFileUseCase(
|
||||
|
||||
val nullableBook = getBookFromFileUseCase(
|
||||
File(
|
||||
name = file.name,
|
||||
uri = file.uri.toString(),
|
||||
|
|
@ -258,7 +260,19 @@ class BrowseModel @Inject constructor(
|
|||
lastModified = file.lastModified,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
@ -294,12 +308,12 @@ class BrowseModel @Inject constructor(
|
|||
is BrowseEvent.OnActionAddDialog -> {
|
||||
withContext(Dispatchers.Default) {
|
||||
_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
|
||||
}.ifEmpty { return@withContext }.forEach { bookWithCover ->
|
||||
}.ifEmpty { return@withContext }.forEach { nullableBook ->
|
||||
addBookUseCase(
|
||||
bookWithCover.book,
|
||||
bookWithCover.coverImage
|
||||
nullableBook.book,
|
||||
nullableBook.coverImage
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import androidx.compose.runtime.Immutable
|
|||
import ua.acclorite.book_story.core.BottomSheet
|
||||
import ua.acclorite.book_story.core.Dialog
|
||||
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
|
||||
data class BrowseState(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -4,10 +4,9 @@
|
|||
* 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 ua.acclorite.book_story.data.model.common.NullableBook
|
||||
|
||||
@Immutable
|
||||
data class SelectableNullableBook(
|
||||
|
|
@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.filled.AddChart
|
||||
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.unit.dp
|
||||
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.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.progress_indicator.CircularProgressIndicator
|
||||
import ua.acclorite.book_story.ui.common.helpers.showToast
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
fun BrowseAddDialog(
|
||||
|
|
@ -63,22 +62,25 @@ fun BrowseAddDialog(
|
|||
}
|
||||
}
|
||||
} else {
|
||||
items(
|
||||
itemsIndexed(
|
||||
selectedBooksAddDialog,
|
||||
key = { it.data.fileName ?: UUID.randomUUID() }
|
||||
) { book ->
|
||||
key = { index, _ -> index }
|
||||
) { _, book ->
|
||||
BrowseAddDialogItem(
|
||||
result = book
|
||||
) {
|
||||
if (it) {
|
||||
when (book.data) {
|
||||
is NullableBook.NotNull -> {
|
||||
selectAddDialog(
|
||||
BrowseEvent.OnSelectAddDialog(
|
||||
book = book
|
||||
)
|
||||
)
|
||||
} else {
|
||||
book.data.message?.asString(context)
|
||||
?.showToast(context = context)
|
||||
}
|
||||
|
||||
is NullableBook.Null -> {
|
||||
book.data.message?.asString(context)?.showToast(context = context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,19 +24,20 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ua.acclorite.book_story.R
|
||||
import ua.acclorite.book_story.data.model.common.NullableBook
|
||||
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.common.CircularCheckbox
|
||||
import ua.acclorite.book_story.ui.common.components.common.StyledText
|
||||
|
||||
@Composable
|
||||
fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: (Boolean) -> Unit) {
|
||||
if (result.data is NullableBook.NotNull) {
|
||||
fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: () -> Unit) {
|
||||
when (result.data) {
|
||||
is NullableBook.NotNull -> {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onClick(true)
|
||||
onClick()
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
|
@ -45,14 +46,14 @@ fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: (Boolean) -> Un
|
|||
Modifier.weight(1f)
|
||||
) {
|
||||
StyledText(
|
||||
text = result.data.bookWithCover!!.book.title,
|
||||
text = result.data.book.title,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
StyledText(
|
||||
text = result.data.bookWithCover.book.author.asString(),
|
||||
text = result.data.book.author.asString(),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
|
|
@ -67,12 +68,14 @@ fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: (Boolean) -> Un
|
|||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
is NullableBook.Null -> {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
onClick(false)
|
||||
onClick()
|
||||
}
|
||||
.padding(vertical = 12.dp, horizontal = 24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
|
|
@ -85,7 +88,7 @@ fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: (Boolean) -> Un
|
|||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
StyledText(
|
||||
text = result.data.fileName!!,
|
||||
text = result.data.fileName,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.error
|
||||
),
|
||||
|
|
@ -94,3 +97,4 @@ fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: (Boolean) -> Un
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.model.BrowseLayout
|
||||
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)
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import androidx.compose.runtime.Composable
|
|||
import ua.acclorite.book_story.core.Dialog
|
||||
import ua.acclorite.book_story.presentation.browse.BrowseEvent
|
||||
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
|
||||
fun BrowseDialog(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue