fix: force disk fallback in LocatorConverter to fix MD/TXT reading position (#40)

Background pre-processing for single-file imports (MD/TXT) was generating empty DB cache entries because it only looked at RAM content. This caused position saves in Vertical Mode to fail. This fix ensures that an empty cache entry triggers a disk-read fallback to generate valid semantic blocks on-demand.
This commit is contained in:
Aryan 2026-03-07 22:34:08 +05:30 committed by GitHub
parent b9f8825ad7
commit 6bc4f9bafd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 104 additions and 51 deletions

View file

@ -60,7 +60,7 @@ class SingleFileImporter(private val context: Context) {
Timber.d("Parsing Markdown with Page-Level Chaptering: $originalBookNameHint") Timber.d("Parsing Markdown with Page-Level Chaptering: $originalBookNameHint")
val title = originalBookNameHint.substringBeforeLast(".") val title = originalBookNameHint.substringBeforeLast(".")
// Read the full markdown content // Read the full Markdown content
val markdownContent = inputStream.bufferedReader().use { it.readText() } val markdownContent = inputStream.bufferedReader().use { it.readText() }
// Flexmark Setup // Flexmark Setup
@ -311,8 +311,8 @@ class SingleFileImporter(private val context: Context) {
private fun createBookFromHtmlBody( private fun createBookFromHtmlBody(
title: String, title: String,
bodyContent: String?, @Suppress("SameParameterValue") bodyContent: String?,
cssStyle: String?, @Suppress("SameParameterValue")cssStyle: String?,
fileName: String, fileName: String,
preGeneratedFullHtml: String? = null, preGeneratedFullHtml: String? = null,
author: String? = null author: String? = null

View file

@ -181,6 +181,9 @@ class CfiJsBridge(
@JavascriptInterface @JavascriptInterface
fun onCfiExtracted(jsonResponse: String) { fun onCfiExtracted(jsonResponse: String) {
try { try {
// --- ADDED LOG ---
Timber.tag("PosSaveDiag").d("CfiJsBridge.onCfiExtracted: Raw JSON received from JS: $jsonResponse")
val json = JSONObject(jsonResponse) val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi", "/4") val cfi = json.optString("cfi", "/4")
val logArray = json.optJSONArray("log") val logArray = json.optJSONArray("log")
@ -200,7 +203,7 @@ class CfiJsBridge(
onCfiReady(cfi) onCfiReady(cfi)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error parsing CFI JSON response: $jsonResponse") Timber.tag("PosSaveDiag").e(e, "CfiJsBridge.onCfiExtracted: Error parsing CFI JSON response: $jsonResponse")
onCfiReady("/4") onCfiReady("/4")
} }
} }

View file

@ -2161,9 +2161,10 @@ fun EpubReaderHost(
showDictionaryUpsellDialog = true showDictionaryUpsellDialog = true
}, },
onCfiGenerated = { cfi -> onCfiGenerated = { cfi ->
Timber.tag("PosSaveDiag").d("EpubReaderScreen: onCfiGenerated callback triggered with CFI: '$cfi'")
if (cfi.isBlank() || !cfi.startsWith('/')) { if (cfi.isBlank() || !cfi.startsWith('/')) {
Timber.w("onCfiGenerated received an invalid CFI, aborting save: '$cfi'" Timber.tag("PosSaveDiag").w("EpubReaderScreen: onCfiGenerated received an invalid CFI, aborting save: '$cfi'")
)
if (isSavingAndExiting) { if (isSavingAndExiting) {
isSavingAndExiting = false isSavingAndExiting = false
onNavigateBack() onNavigateBack()
@ -2172,6 +2173,7 @@ fun EpubReaderHost(
} }
scope.launch { scope.launch {
Timber.tag("PosSaveDiag").d("EpubReaderScreen: Requesting locator conversion for chapter $latestChapterIndex")
val locator = val locator =
locatorConverter.getLocatorFromCfi( locatorConverter.getLocatorFromCfi(
epubBook, epubBook,
@ -2179,10 +2181,11 @@ fun EpubReaderHost(
cfi cfi
) )
Timber.tag("PosSaveDiag").d("EpubReaderScreen: Locator conversion returned: $locator")
if (locator != null) { if (locator != null) {
lastKnownLocator = locator lastKnownLocator = locator
// Calculate progress (Logic moved out of `val progress` block for scope visibility)
val progressWithinChapter = val progressWithinChapter =
if (currentScrollHeightValue > currentClientHeightValue) { if (currentScrollHeightValue > currentClientHeightValue) {
val scrollableHeight = val scrollableHeight =
@ -2945,7 +2948,7 @@ fun EpubReaderHost(
} else { } else {
scope.launch { scope.launch {
lastKnownLocator?.let { locator -> lastKnownLocator?.let { locator ->
val cfi = locatorConverter.getCfiFromLocator(epubBook.title, locator) val cfi = locatorConverter.getCfiFromLocator(epubBook, locator)
if (cfi != null) { if (cfi != null) {
val targetChunk = locator.blockIndex / 20 val targetChunk = locator.blockIndex / 20
chunkTargetOverride = targetChunk chunkTargetOverride = targetChunk

View file

@ -33,6 +33,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoBuf
import java.io.File
data class Locator( data class Locator(
val chapterIndex: Int, val chapterIndex: Int,
@ -50,8 +51,41 @@ class LocatorConverter(
private val context: Context private val context: Context
) { ) {
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) { private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
Timber.tag("PosSaveDiag").d("processAndCacheChapter: STARTED for book='${book.title}', chapterIndex=$chapterIndex")
try { try {
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null val chapter = book.chapters.getOrNull(chapterIndex)
if (chapter == null) {
Timber.tag("PosSaveDiag").e("processAndCacheChapter: FAILED. Chapter is null for index $chapterIndex")
return@withContext null
}
Timber.tag("PosSaveDiag").d("processAndCacheChapter: Checking HTML content. RAM content length: ${chapter.htmlContent.length}")
val htmlToParse = if (chapter.htmlContent.isNotBlank()) {
Timber.tag("PosSaveDiag").d("processAndCacheChapter: Using HTML from RAM.")
chapter.htmlContent
} else {
Timber.tag("PosSaveDiag").d("processAndCacheChapter: RAM HTML is blank. Falling back to disk. Path: ${book.extractionBasePath} / ${chapter.htmlFilePath}")
try {
val file = File(book.extractionBasePath, chapter.htmlFilePath)
if (file.exists()) {
val content = file.readText()
Timber.tag("PosSaveDiag").d("processAndCacheChapter: Read file from disk SUCCESS. Content length: ${content.length}")
content
} else {
Timber.tag("PosSaveDiag").e("processAndCacheChapter: File DOES NOT EXIST at ${file.absolutePath}")
""
}
} catch (e: Exception) {
Timber.tag("PosSaveDiag").e(e, "processAndCacheChapter: Exception reading chapter file from disk")
""
}
}
if (htmlToParse.isBlank()) {
Timber.tag("PosSaveDiag").w("processAndCacheChapter: Final HTML to parse is blank. Aborting semantic block generation.")
return@withContext null
}
val mergedByTag = mutableMapOf<String, MutableList<CssRule>>() val mergedByTag = mutableMapOf<String, MutableList<CssRule>>()
val mergedByClass = mutableMapOf<String, MutableList<CssRule>>() val mergedByClass = mutableMapOf<String, MutableList<CssRule>>()
@ -95,8 +129,9 @@ class LocatorConverter(
otherComplex = mergedOtherComplex otherComplex = mergedOtherComplex
) )
Timber.tag("PosSaveDiag").d("processAndCacheChapter: Calling htmlToSemanticBlocks...")
val semanticBlocks = htmlToSemanticBlocks( val semanticBlocks = htmlToSemanticBlocks(
html = chapter.htmlContent, html = htmlToParse,
cssRules = parsingCssRules, cssRules = parsingCssRules,
textStyle = TextStyle(), textStyle = TextStyle(),
chapterAbsPath = chapter.absPath, chapterAbsPath = chapter.absPath,
@ -105,8 +140,11 @@ class LocatorConverter(
fontFamilyMap = emptyMap(), fontFamilyMap = emptyMap(),
constraints = constraints constraints = constraints
) )
Timber.tag("PosSaveDiag").d("processAndCacheChapter: htmlToSemanticBlocks returned ${semanticBlocks.size} blocks.")
val protoBytes = proto.encodeToByteArray(semanticBlocks) val protoBytes = proto.encodeToByteArray(semanticBlocks)
Timber.tag("PosSaveDiag").d("processAndCacheChapter: Encoded blocks to protoBytes (size: ${protoBytes.size} bytes).")
val newCacheEntry = ProcessedChapter( val newCacheEntry = ProcessedChapter(
bookId = book.title, bookId = book.title,
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
@ -114,50 +152,48 @@ class LocatorConverter(
estimatedPageCount = 0 estimatedPageCount = 0
) )
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry)) bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
Timber.i("On-demand processing SUCCESS for chapter $chapterIndex.") Timber.tag("PosSaveDiag").i("processAndCacheChapter: On-demand processing and DB caching SUCCESS for chapter $chapterIndex.")
semanticBlocks semanticBlocks
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "On-demand processing FAILED for chapter $chapterIndex") Timber.tag("PosSaveDiag").e(e, "processAndCacheChapter: FAILED for chapter $chapterIndex")
null null
} }
} }
/**
* Converts a CFI string from the WebView into an abstract Locator.
*/
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) { suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) {
Timber.d("getLocatorFromCfi: Starting conversion for book='${book.title}', chapter=$chapterIndex, cfi='$cfi'")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex) val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
val allBlocks = if (processedChapter != null) {
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
allBlocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto) proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} else { } catch (_: Exception) { null }
Timber.w("getLocatorFromCfi: Chapter $chapterIndex not in DB. Triggering on-demand processing.")
processAndCacheChapter(book, chapterIndex)
} }
if (allBlocks == null) { if (allBlocks.isNullOrEmpty()) {
Timber.w("getLocatorFromCfi: FAILED. Could not get or process semantic blocks for chapter $chapterIndex.") Timber.tag("PosSaveDiag").w("getLocatorFromCfi: Cache missing or empty for chapter $chapterIndex. Triggering on-demand processing.")
allBlocks = processAndCacheChapter(book, chapterIndex)
}
if (allBlocks.isNullOrEmpty()) {
Timber.tag("PosSaveDiag").e("getLocatorFromCfi: FAILED. Could not get or process semantic blocks.")
return@withContext null return@withContext null
} }
val (baseCfiPath, charOffset) = cfi.split(':').let { val (baseCfiPath, charOffset) = cfi.split(':').let {
it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 0) it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 0)
} }
Timber.d("getLocatorFromCfi: Parsed CFI into basePath='$baseCfiPath' and charOffset=$charOffset")
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath) val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
if (bestMatch != null) { if (bestMatch != null) {
Timber.i("getLocatorFromCfi: SUCCESS. Found best match. Block index: ${bestMatch.blockIndex}, Block CFI: '${bestMatch.cfi}'")
Locator( Locator(
chapterIndex = chapterIndex, chapterIndex = chapterIndex,
blockIndex = bestMatch.blockIndex, blockIndex = bestMatch.blockIndex,
charOffset = charOffset charOffset = charOffset
) )
} else { } else {
Timber.w("getLocatorFromCfi: FAILED. No matching block found for CFI base path '$baseCfiPath'.")
null null
} }
} }
@ -200,29 +236,31 @@ class LocatorConverter(
return bestMatch return bestMatch
} }
suspend fun getCfiFromLocator(bookId: String, locator: Locator): String? = withContext(Dispatchers.IO) { suspend fun getCfiFromLocator(book: EpubBook, locator: Locator): String? = withContext(Dispatchers.IO) {
Timber.d("getCfiFromLocator: Attempting to get CFI from locator: $locator") val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
val processedChapter = bookCacheDao.getProcessedChapter(bookId = bookId, chapterIndex = locator.chapterIndex)
if (processedChapter == null) { var blocks: List<SemanticBlock>? = null
Timber.w("getCfiFromLocator: FAILED. Could not find processed chapter ${locator.chapterIndex} in database.") if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
blocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} catch (_: Exception) { null }
}
if (blocks.isNullOrEmpty()) {
blocks = processAndCacheChapter(book, locator.chapterIndex)
}
if (blocks.isNullOrEmpty()) {
return@withContext null return@withContext null
} }
val blocks = proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex) val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex)
if (foundBlock != null) { foundBlock?.cfi?.let { cfi ->
foundBlock.cfi?.let { cfi -> if (locator.charOffset > 0) {
val finalCfi = if (locator.charOffset > 0) {
"$cfi:${locator.charOffset}" "$cfi:${locator.charOffset}"
} else { } else {
cfi cfi
} }
Timber.i("getCfiFromLocator: SUCCESS. Found block ${foundBlock.blockIndex} with CFI '${foundBlock.cfi}'. Final CFI: '$finalCfi'")
finalCfi
}
} else {
Timber.w("getCfiFromLocator: FAILED. Could not find block with index ${locator.blockIndex} in chapter ${locator.chapterIndex}.")
null
} }
} }
@ -249,11 +287,19 @@ class LocatorConverter(
suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) { suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex) val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
val allBlocks = if (processedChapter != null) {
var allBlocks: List<SemanticBlock>? = null
if (processedChapter != null && processedChapter.contentBlocksProto.isNotEmpty()) {
allBlocks = try {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto) proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} else { } catch(_: Exception) { null }
processAndCacheChapter(book, locator.chapterIndex) }
} ?: return@withContext null
if (allBlocks.isNullOrEmpty()) {
allBlocks = processAndCacheChapter(book, locator.chapterIndex)
}
if (allBlocks.isNullOrEmpty()) return@withContext null
var offset = 0 var offset = 0
val separatorLength = 1 val separatorLength = 1

View file

@ -1677,6 +1677,7 @@ fun PdfViewerScreen(
) )
if (result == SnackbarResult.ActionPerformed) { if (result == SnackbarResult.ActionPerformed) {
snackbarHostState.currentSnackbarData?.dismiss()
val item = uiState.recentFiles.find { it.bookId == reflowBookId } val item = uiState.recentFiles.find { it.bookId == reflowBookId }
if (item != null) { if (item != null) {
viewModel.onRecentFileClicked(item) viewModel.onRecentFileClicked(item)