More formats support (#161)

* Added support for ODT and FODT file formats.

* Improved `OdtParser` to support enhanced document formatting and external assets

* Added support for viewing CSV, TSV, JSON, XML, logs, and source code files by dynamically converting them to HTML.
This commit is contained in:
Aryan 2026-04-10 17:35:31 +05:30 committed by GitHub
parent 6a60aec0ef
commit 291504fd90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 652 additions and 76 deletions

View file

@ -136,7 +136,7 @@ enum class AddBooksSource(val displayName: String) {
}
enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7, DOCX, ODT, FODT
}
enum class RenderMode {
@ -254,6 +254,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext)
private val odtParser = com.aryan.reader.epub.OdtParser(appContext)
private val singleFileImporter = SingleFileImporter(appContext)
private val bookImporter = BookImporter(appContext)
private val prefs: SharedPreferences =
@ -2536,7 +2537,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
var author: String? = null
var bookForMetadata = epubBook
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX)) {
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT)) {
Timber.d("Parsing downloaded book for cover/metadata: $displayName")
Timber.tag("FileOpenPerf")
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
@ -2570,6 +2571,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
parseContent = false
)
}
FileType.ODT, FileType.FODT -> {
odtParser.createOdtBook(
inputStream = inputStream,
bookId = bookId,
originalBookNameHint = displayName,
isFlat = type == FileType.FODT,
parseContent = false
)
}
else -> {
singleFileImporter.importSingleFile(
inputStream,
@ -2598,7 +2608,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val finalBookMetadata = bookForMetadata
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) && finalBookMetadata != null) {
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) && finalBookMetadata != null) {
title =
finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
@ -3203,7 +3213,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileSwitch").d("PDF state updated, suppressing navigation event for smooth transition")
}
}
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX) {
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML || type == FileType.DOCX || type == FileType.ODT || type == FileType.FODT) {
viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId)
if (recentItem?.sourceFolderUri != null) {
@ -3251,7 +3261,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
FileType.FB2 -> {
loadFb2(uri, bookId, customDisplayName = originalDisplayName)
}
FileType.ODT, FileType.FODT -> {
loadOdt(uri, bookId, type == FileType.FODT, customDisplayName = originalDisplayName)
}
else -> {
loadSingleFile(
uri, bookId, type, customDisplayName = originalDisplayName
@ -3299,6 +3311,43 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private fun loadOdt(uri: Uri, bookId: String, isFlat: Boolean, customDisplayName: String? = null) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt START | isFlat=$isFlat")
viewModelScope.launch {
if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
}
Timber.d("Starting ODT parsing for URI: $uri")
try {
val odtBook = withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri).use { inputStream ->
if (inputStream == null) throw Exception("Could not open input stream")
odtParser.createOdtBook(
inputStream = inputStream,
bookId = bookId,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: if (isFlat) "unknown.fodt" else "unknown.odt",
isFlat = isFlat
)
}
}
Timber.i("ODT parsing successful. Title: ${odtBook.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadOdt completed | chapters=${odtBook.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
addFileToRecent(
uri, if (isFlat) FileType.FODT else FileType.ODT, bookId, odtBook, customDisplayName, isRecent = true, sourceFolderUri = null
)
_internalState.update { it.copy(selectedEpubBook = odtBook, isLoading = false) }
} catch (e: Exception) {
Timber.e(e, "Error parsing ODT for URI: $uri")
_internalState.update {
it.copy(errorMessage = appContext.getString(R.string.error_load_file, e.message), isLoading = false)
}
}
}
}
private fun loadSingleFile(
uri: Uri,
bookId: String,
@ -3366,6 +3415,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName")
return when (mimeType) {
"application/vnd.oasis.opendocument.text" -> FileType.ODT
"application/x-vnd.oasis.opendocument.text-flat-xml" -> FileType.FODT
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" -> FileType.DOCX
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ else null
@ -3382,13 +3433,26 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
"application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
"text/markdown", "text/x-markdown" -> FileType.MD
"text/html", "application/xhtml+xml" -> FileType.HTML
"text/csv", "text/comma-separated-values", "text/tab-separated-values",
"application/json", "application/xml", "text/xml",
"text/x-java-source", "text/x-python", "text/x-kotlin",
"text/javascript", "application/javascript",
"text/x-c", "text/x-c++", "text/x-csharp", "text/x-ruby", "text/x-go", "text/x-log" -> FileType.HTML
"text/plain" -> {
if (fileName?.endsWith(
".md",
ignoreCase = true
) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true
) {
if (fileName?.endsWith(".md", ignoreCase = true) == true || fileName?.endsWith(".markdown", ignoreCase = true) == true) {
FileType.MD
} else if (fileName?.let {
it.endsWith(".csv", ignoreCase = true) || it.endsWith(".tsv", ignoreCase = true) ||
it.endsWith(".json", ignoreCase = true) || it.endsWith(".xml", ignoreCase = true) ||
it.endsWith(".log", ignoreCase = true) || it.endsWith(".java", ignoreCase = true) ||
it.endsWith(".kt", ignoreCase = true) || it.endsWith(".py", ignoreCase = true) ||
it.endsWith(".js", ignoreCase = true) || it.endsWith(".cpp", ignoreCase = true) ||
it.endsWith(".c", ignoreCase = true) || it.endsWith(".cs", ignoreCase = true) ||
it.endsWith(".rb", ignoreCase = true) || it.endsWith(".go", ignoreCase = true)
} == true) {
FileType.HTML
} else {
FileType.TXT
}
@ -3439,6 +3503,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
ignoreCase = true
) == true -> FileType.HTML
fileName?.endsWith(".docx", ignoreCase = true) == true -> FileType.DOCX
fileName?.endsWith(".odt", ignoreCase = true) == true -> FileType.ODT
fileName?.endsWith(".fodt", ignoreCase = true) == true -> FileType.FODT
fileName?.endsWith(".csv", ignoreCase = true) == true ||
fileName?.endsWith(".tsv", ignoreCase = true) == true ||
fileName?.endsWith(".json", ignoreCase = true) == true ||
fileName?.endsWith(".xml", ignoreCase = true) == true ||
fileName?.endsWith(".log", ignoreCase = true) == true ||
fileName?.endsWith(".java", ignoreCase = true) == true ||
fileName?.endsWith(".kt", ignoreCase = true) == true ||
fileName?.endsWith(".py", ignoreCase = true) == true ||
fileName?.endsWith(".js", ignoreCase = true) == true ||
fileName?.endsWith(".cpp", ignoreCase = true) == true ||
fileName?.endsWith(".c", ignoreCase = true) == true ||
fileName?.endsWith(".cs", ignoreCase = true) == true ||
fileName?.endsWith(".rb", ignoreCase = true) == true ||
fileName?.endsWith(".go", ignoreCase = true) == true -> FileType.HTML
else -> null
}