merge: parsers-improvement into master

This commit is contained in:
Acclorite 2025-11-13 10:07:37 +02:00
commit df818e3081
No known key found for this signature in database
GPG key ID: 6E54C611F6EE8593
43 changed files with 574 additions and 422 deletions

View file

@ -22,10 +22,12 @@ 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.FileParser
import ua.acclorite.book_story.data.parser.FileParserImpl
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.parser.TextParserImpl
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
import ua.acclorite.book_story.data.parser.text.TextParserImpl
import ua.acclorite.book_story.data.repository.BookRepositoryImpl
import ua.acclorite.book_story.data.repository.CategoryRepositoryImpl
import ua.acclorite.book_story.data.repository.ColorPresetRepositoryImpl
@ -121,6 +123,12 @@ abstract class RepositoryModule {
fileParserImpl: FileParserImpl
): FileParser
@Binds
@Singleton
abstract fun bindCoverParser(
coverParserImpl: CoverParserImpl
): CoverParser
@Binds
@Singleton
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

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser
package ua.acclorite.book_story.data.parser.document
import android.graphics.Bitmap
import android.graphics.BitmapFactory

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser
package ua.acclorite.book_story.data.parser.document
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
@ -30,7 +30,7 @@ class MarkdownParser @Inject constructor(
private val commonmarkParser: Parser
) {
/**
* Parses markdown text to [androidx.compose.ui.text.AnnotatedString].
* Parses markdown text to [AnnotatedString].
*
* @return Parsed annotated string.
*/

View file

@ -1,132 +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.parser.epub
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.data.parser.FileParser
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? {
return try {
var book: BookWithCover? = 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 title = document.select("metadata > dc|title").text().trim().run {
ifBlank {
cachedFile.name.substringBeforeLast(".").trim()
}
}
val author = document.select("metadata > dc|creator").text().trim().run {
if (isBlank()) {
UIText.StringResource(R.string.unknown_author)
} else {
UIText.StringValue(this)
}
}
val description = Jsoup.parse(
document.select("metadata > dc|description").text()
).text().run {
ifBlank {
null
}
}
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,
description = description,
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = cachedFile.path,
lastOpened = null,
categories = emptyList(),
coverImage = null
),
coverImage = extractCoverImageBitmap(rawFile, coverImage)
)
}
}
book
} catch (e: Exception) {
e.printStackTrace()
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

@ -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.file
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.file.CachedFile
import ua.acclorite.book_story.domain.model.library.Book
import java.util.zip.ZipFile
import javax.inject.Inject
class EpubFileParser @Inject constructor() : FileParser {
override suspend fun parse(cachedFile: CachedFile): Book? {
return try {
var book: Book? = 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 title = document.select("metadata > dc|title").text().trim().run {
ifBlank {
cachedFile.name.substringBeforeLast(".").trim()
}
}
val author = document.select("metadata > dc|creator").text().trim().run {
if (isBlank()) {
UIText.StringResource(R.string.unknown_author)
} else {
UIText.StringValue(this)
}
}
val description = Jsoup.parse(
document.select("metadata > dc|description").text()
).text().run {
ifBlank {
null
}
}
book = Book(
title = title,
author = author,
description = description,
scrollIndex = 0,
scrollOffset = 0,
progress = 0f,
filePath = cachedFile.path,
lastOpened = null,
categories = emptyList(),
coverImage = null
)
}
}
book
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}

View file

@ -4,21 +4,19 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.fb2
package ua.acclorite.book_story.data.parser.file
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.data.parser.FileParser
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())
@ -45,8 +43,7 @@ class Fb2FileParser @Inject constructor() : FileParser {
this
}
BookWithCover(
book = Book(
Book(
title = title,
author = author,
description = description,
@ -57,8 +54,6 @@ class Fb2FileParser @Inject constructor() : FileParser {
lastOpened = null,
categories = emptyList(),
coverImage = null
),
coverImage = null
)
} catch (e: Exception) {
e.printStackTrace()

View file

@ -4,11 +4,11 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser
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?
}

View file

@ -4,16 +4,11 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser
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.data.parser.epub.EpubFileParser
import ua.acclorite.book_story.data.parser.fb2.Fb2FileParser
import ua.acclorite.book_story.data.parser.html.HtmlFileParser
import ua.acclorite.book_story.data.parser.pdf.PdfFileParser
import ua.acclorite.book_story.data.parser.txt.TxtFileParser
import ua.acclorite.book_story.domain.model.library.Book
import javax.inject.Inject
private const val FILE_PARSER = "File Parser"
@ -26,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

View file

@ -4,21 +4,19 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.html
package ua.acclorite.book_story.data.parser.file
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.data.parser.FileParser
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())
@ -31,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,
@ -43,8 +40,6 @@ class HtmlFileParser @Inject constructor() : FileParser {
lastOpened = null,
categories = emptyList(),
coverImage = null
),
coverImage = null
)
} catch (e: Exception) {
e.printStackTrace()

View file

@ -4,16 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.pdf
package ua.acclorite.book_story.data.parser.file
import android.app.Application
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.data.parser.FileParser
import ua.acclorite.book_story.domain.model.library.Book
import javax.inject.Inject
@ -21,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())
@ -36,8 +34,7 @@ class PdfFileParser @Inject constructor(
document.close()
BookWithCover(
book = Book(
Book(
title = title,
author = author,
description = description,
@ -48,8 +45,6 @@ class PdfFileParser @Inject constructor(
lastOpened = null,
categories = emptyList(),
coverImage = null
),
coverImage = null
)
} catch (e: Exception) {
e.printStackTrace()

View file

@ -4,25 +4,22 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.txt
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.data.parser.FileParser
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,
@ -33,8 +30,6 @@ class TxtFileParser @Inject constructor() : FileParser {
lastOpened = null,
categories = emptyList(),
coverImage = null
),
coverImage = null
)
} catch (e: Exception) {
e.printStackTrace()

View file

@ -6,7 +6,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package ua.acclorite.book_story.data.parser.epub
package ua.acclorite.book_story.data.parser.text
import android.util.Log
import androidx.core.net.toUri
@ -22,8 +22,7 @@ import ua.acclorite.book_story.core.data.ExtensionsData
import ua.acclorite.book_story.core.helpers.addAll
import ua.acclorite.book_story.core.helpers.containsVisibleText
import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.data.parser.DocumentParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.parser.document.DocumentParser
import ua.acclorite.book_story.domain.model.reader.ReaderText
import java.io.File
import java.net.URLDecoder

View file

@ -4,15 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.html
package ua.acclorite.book_story.data.parser.text
import android.util.Log
import kotlinx.coroutines.yield
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.data.parser.DocumentParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.parser.document.DocumentParser
import ua.acclorite.book_story.domain.model.reader.ReaderText
import javax.inject.Inject

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.pdf
package ua.acclorite.book_story.data.parser.text
import android.app.Application
import android.util.Log
@ -14,8 +14,7 @@ import com.tom_roush.pdfbox.text.PDFTextStripper
import kotlinx.coroutines.yield
import ua.acclorite.book_story.core.helpers.clearAllMarkdown
import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.parser.document.MarkdownParser
import ua.acclorite.book_story.domain.model.reader.ReaderText
import javax.inject.Inject

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser
package ua.acclorite.book_story.data.parser.text
import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.domain.model.reader.ReaderText

View file

@ -4,17 +4,12 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser
package ua.acclorite.book_story.data.parser.text
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.data.parser.epub.EpubTextParser
import ua.acclorite.book_story.data.parser.html.HtmlTextParser
import ua.acclorite.book_story.data.parser.pdf.PdfTextParser
import ua.acclorite.book_story.data.parser.txt.TxtTextParser
import ua.acclorite.book_story.data.parser.xml.XmlTextParser
import ua.acclorite.book_story.domain.model.reader.ReaderText
import javax.inject.Inject

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.txt
package ua.acclorite.book_story.data.parser.text
import android.util.Log
import kotlinx.coroutines.Dispatchers
@ -12,8 +12,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import ua.acclorite.book_story.core.helpers.clearAllMarkdown
import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.data.parser.MarkdownParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.parser.document.MarkdownParser
import ua.acclorite.book_story.domain.model.reader.ReaderText
import javax.inject.Inject

View file

@ -4,15 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
package ua.acclorite.book_story.data.parser.xml
package ua.acclorite.book_story.data.parser.text
import android.util.Log
import kotlinx.coroutines.yield
import org.jsoup.Jsoup
import org.jsoup.parser.Parser
import ua.acclorite.book_story.data.model.file.CachedFile
import ua.acclorite.book_story.data.parser.DocumentParser
import ua.acclorite.book_story.data.parser.TextParser
import ua.acclorite.book_story.data.parser.document.DocumentParser
import ua.acclorite.book_story.domain.model.reader.ReaderText
import javax.inject.Inject

View file

@ -12,8 +12,8 @@ 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.FileParser
import ua.acclorite.book_story.data.parser.TextParser
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
import ua.acclorite.book_story.domain.model.reader.ReaderText
@ -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)
}
}
}
}

View file

@ -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.FileParser
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
}
}
}

View file

@ -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?>>
}

View file

@ -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()

View file

@ -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) {

View file

@ -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 {

View file

@ -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
}
)
}

View file

@ -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 {

View file

@ -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
)
}

View file

@ -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(

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
*/
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(

View file

@ -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)
}
}
}
}

View file

@ -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
),
@ -93,4 +96,5 @@ fun BrowseAddDialogItem(result: SelectableNullableBook, onClick: (Boolean) -> Un
)
}
}
}
}

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.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

View file

@ -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(