From a81df6921d08347f4e0e0950c2aedf082f3324d2 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sun, 1 Mar 2026 22:57:53 +0530 Subject: [PATCH] Folder import\sync rework (#18) * feat: rework Folder Sync architecture to separate Managed vs Linked books - Introduced "Managed" (imported to app storage) vs "Linked" (SAF URI) book logic. - Disabled Google Drive/Metadata synchronization for Folder-Linked books to respect privacy and storage. - Updated deletion logic: Folder books are now marked as deleted in the DB to blacklist them from future auto-scans, while Managed books are permanently purged from local and cloud storage. - Enhanced folder sync discoverability by adding a direct "Sync Folder" navigation button in Home screen. - Implemented reactive pager navigation in MainScreen and LibraryScreen via LaunchedEffect to allow programmatic tab switching from the ViewModel. * Implemented a local folder synchronization system to track and update book metadata across devices. Key changes: - Added `FolderBookMetadata` and `LocalSyncUtils` for serializing and managing metadata files in a `.episteme` directory. - Implemented bidirectional sync in `FolderSyncWorker` to reconcile local database state with folder-based metadata using timestamps and Syncthing conflict resolution. - Updated `MainViewModel` to trigger folder metadata sync when closing a book and verify for remote updates upon opening. - Enhanced folder management with manual scan support via `WorkManager` and automatic cleanup of database entries when a folder is disconnected. - Added `RecentFileDao` methods to support bulk deletion and prefix-based file lookups. * Removed API 34 restriction for chapter processing and updated local folder sync directory name * Increased TTS timeouts, added edge-to-edge support in MainScreen, and improved TtsChunk mapping safety * Improved folder synchronization and metadata management. - Added metadata-only sync option and triggered it on app start. - Implemented hidden metadata filenames (prefixed with `.`) to reduce file clutter. - Added automatic creation of `.nomedia` files in the sync directory. - Enhanced sync conflict resolution by picking the latest metadata and cleaning up obsolete or orphaned files. - Added "Sync Metadata" button to the Library screen. - Updated UI labels for local folder sync to clarify Google Drive integration. - Refactored `FolderSyncWorker` to support targeted metadata-only synchronization. * Implement folder sync migration and refactoring - Added a migration dialog to inform users about the folder sync refactor, where books are now read directly from external storage. - Updated `MainViewModel` to handle migration state and trigger a full scan upon completion or dismissal of the dialog. - Modified `FolderSyncWorker` to support legacy book matching during migration, updating file URIs and cleaning up internal app storage for migrated books. - Improved synchronization logic between local and remote metadata, including cleanup of orphaned metadata files. - Refined `RecentFileItem` updates during sync to preserve progress and bookmarks based on last modified timestamps. * Implemented pull-to-refresh for library sync and enhanced folder file deletion. - Added `isRefreshing` state to `MainViewModel` and integrated `PullToRefreshBox` in `HomeScreen`. - Implemented `refreshLibrary` to trigger cloud and folder metadata synchronization. - Updated deletion logic to physically remove files and metadata from synced local folders. - Added a warning to the delete confirmation dialog when removing folder-synced items. - Improved folder sync reliability by performing lazy cleanup of missing files during interaction and sync. - Fixed a bug where sync loading indicators would not retract on failure or cancellation. * Improved bookmark navigation and scroll synchronization in EPUB reader - Refined bookmark navigation logic for vertical scroll mode to handle chunk injection more reliably. - Added `isNavigatingToBookmark` state to show a loading overlay during long jumps. - Implemented `onScrollFinished` callback in `CfiJsBridge` to synchronize UI state with WebView scroll completion. - Updated `ChapterWebView` to use `rememberUpdatedState` for JavaScript bridge callbacks to ensure data consistency. - Improved `getCurrentCfi` and `scrollToCfi` in `epub_reader.js` with better visibility probing and retry logic for detached nodes. * Implemented background metadata extraction for folder sync. Key changes: - Added `MetadataExtractionWorker` to handle heavy metadata and cover extraction for files in the background. - Refactored `FolderSyncWorker` to perform fast file discovery using placeholders, enqueuing the metadata worker upon completion. - Added `getFolderBooksWithoutCovers` query to `RecentFileDao`. - Updated UI components (`EmptyState`, `MainViewModel`) to improve user feedback during sync and provide clear setup actions. - Enhanced `EmptyState` composable to support secondary actions and custom button text. * Updated Library Screen UI --- .../reader/epubreader/EpubTestActivity.kt | 2 - app/src/main/assets/epub_reader.js | 3076 ++++++++--------- .../java/com/aryan/reader/AppNavigation.kt | 4 - .../java/com/aryan/reader/FolderSyncWorker.kt | 438 ++- .../main/java/com/aryan/reader/HomeScreen.kt | 277 +- .../java/com/aryan/reader/LibraryScreen.kt | 340 +- .../main/java/com/aryan/reader/MainScreen.kt | 22 +- .../java/com/aryan/reader/MainViewModel.kt | 812 +++-- .../aryan/reader/MetadataExtractionWorker.kt | 115 + .../com/aryan/reader/SharedComposables.kt | 50 +- .../aryan/reader/data/FolderBookMetadata.kt | 102 + .../com/aryan/reader/data/LocalSyncUtils.kt | 260 ++ .../com/aryan/reader/data/RecentFileDao.kt | 13 + .../reader/data/RecentFilesRepository.kt | 54 +- .../aryan/reader/epubreader/ChapterWebView.kt | 46 +- .../reader/epubreader/EpubReaderScreen.kt | 185 +- .../aryan/reader/paginatedreader/CssParser.kt | 1 - .../aryan/reader/paginatedreader/Locator.kt | 14 +- .../com/aryan/reader/pdf/PdfViewerScreen.kt | 35 - .../aryan/reader/tts/BaseTtsSynthesizer.kt | 6 +- .../aryan/reader/tts/TtsPlaybackManager.kt | 4 +- 21 files changed, 3378 insertions(+), 2478 deletions(-) create mode 100644 app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt create mode 100644 app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt create mode 100644 app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt diff --git a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt b/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt index 48808bc..c72a3e1 100644 --- a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt +++ b/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt @@ -24,8 +24,6 @@ class EpubTestActivity : ComponentActivity() { initialCfi = null, initialBookmarksJson = null, isProUser = false, - pendingSyncUpdate = null, - onClearPendingSyncUpdate = {}, onNavigateBack = {}, onSavePosition = { _, _, _ -> }, onBookmarksChanged = {}, diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index ba55f6d..f97513a 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -1,26 +1,26 @@ // epub_reader.js (function () { - function applyMobileOptimizationsAndSelection() { - var viewport=document.querySelector("meta[name=viewport]"); + function applyMobileOptimizationsAndSelection() { + var viewport = document.querySelector("meta[name=viewport]"); - if ( !viewport) { - viewport=document.createElement('meta'); - viewport.setAttribute('name', 'viewport'); - document.head.appendChild(viewport); - } + if (!viewport) { + viewport = document.createElement("meta"); + viewport.setAttribute("name", "viewport"); + document.head.appendChild(viewport); + } - viewport.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.5, user-scalable=yes'); + viewport.setAttribute("content", "width=device-width, initial-scale=1.0, maximum-scale=1.5, user-scalable=yes"); - var style=document.getElementById('customMobileStyle'); + var style = document.getElementById("customMobileStyle"); - if ( !style) { - style=document.createElement('style'); - style.setAttribute('id', 'customMobileStyle'); - document.head.appendChild(style); - } + if (!style) { + style = document.createElement("style"); + style.setAttribute("id", "customMobileStyle"); + document.head.appendChild(style); + } - // CHANGED: Colors now use rgba() with 0.5 opacity for blending - style.innerHTML=` html { + // CHANGED: Colors now use rgba() with 0.5 opacity for blending + style.innerHTML = ` html { margin: 0; padding: 0; height: 100%; overflow-y: scroll; } @@ -167,51 +167,48 @@ `; - window.setTextSelectionEnabled=function (enabled) { - var selectStyle=enabled ? 'auto' : 'none'; + window.setTextSelectionEnabled = function (enabled) { + var selectStyle = enabled ? "auto" : "none"; - if (document.body) { - document.body.style.webkitUserSelect=selectStyle; - document.body.style.mozUserSelect=selectStyle; - document.body.style.msUserSelect=selectStyle; - document.body.style.userSelect=selectStyle; - } + if (document.body) { + document.body.style.webkitUserSelect = selectStyle; + document.body.style.mozUserSelect = selectStyle; + document.body.style.msUserSelect = selectStyle; + document.body.style.userSelect = selectStyle; } + }; - ; - window.setTextSelectionEnabled(true); + window.setTextSelectionEnabled(true); + } + + window.VIEWPORT_PADDING_TOP = 0; + window.VIEWPORT_PADDING_BOTTOM = 0; + + window.setViewportPadding = function (top, bottom) { + window.VIEWPORT_PADDING_TOP = top || 0; + window.VIEWPORT_PADDING_BOTTOM = bottom || 0; + }; + + window.applyReaderTheme = function (isDark) { + var styleId = "readerThemeStyle"; + var themeStyleElement = document.getElementById(styleId); + + if (!themeStyleElement) { + themeStyleElement = document.createElement("style"); + themeStyleElement.setAttribute("id", styleId); + document.head.appendChild(themeStyleElement); } - window.VIEWPORT_PADDING_TOP=0; - window.VIEWPORT_PADDING_BOTTOM=0; + // Set a class on the root element for theme state + var themeClassName = isDark ? "dark-theme" : "light-theme"; + var oppositeThemeClassName = isDark ? "light-theme" : "dark-theme"; + document.documentElement.classList.remove(oppositeThemeClassName); + document.documentElement.classList.add(themeClassName); - window.setViewportPadding=function (top, bottom) { - window.VIEWPORT_PADDING_TOP=top || 0; - window.VIEWPORT_PADDING_BOTTOM=bottom || 0; - } + var css = ""; - ; - - window.applyReaderTheme=function (isDark) { - var styleId='readerThemeStyle'; - var themeStyleElement=document.getElementById(styleId); - - if ( !themeStyleElement) { - themeStyleElement=document.createElement('style'); - themeStyleElement.setAttribute('id', styleId); - document.head.appendChild(themeStyleElement); - } - - // Set a class on the root element for theme state - var themeClassName=isDark ? 'dark-theme' : 'light-theme'; - var oppositeThemeClassName=isDark ? 'light-theme' : 'dark-theme'; - document.documentElement.classList.remove(oppositeThemeClassName); - document.documentElement.classList.add(themeClassName); - - var css=""; - - if (isDark) { - css=` html.dark-theme, html.dark-theme body { + if (isDark) { + css = ` html.dark-theme, html.dark-theme body { background-color: #121212 !important; color: #E0E0E0 !important; } @@ -252,524 +249,518 @@ } `; - } - - else { - css=` html.light-theme { + } else { + css = ` html.light-theme { background-color: #FFFFFF; } `; - } - - themeStyleElement.innerHTML=css; } - ; + themeStyleElement.innerHTML = css; + }; - function handleHighlightInteraction(e) { - var target=e.target; - var highlightSpan=null; + function handleHighlightInteraction(e) { + var target = e.target; + var highlightSpan = null; - while (target && target !==document.body) { - var isHighlight = false; - if (target.nodeType === Node.ELEMENT_NODE && target.classList) { - for (var i = 0; i < target.classList.length; i++) { - if (target.classList[i].startsWith('user-highlight-')) { - isHighlight = true; - break; - } + while (target && target !== document.body) { + var isHighlight = false; + if (target.nodeType === Node.ELEMENT_NODE && target.classList) { + for (var i = 0; i < target.classList.length; i++) { + if (target.classList[i].startsWith("user-highlight-")) { + isHighlight = true; + break; } } - - if (isHighlight) { - highlightSpan=target; - break; - } - - target=target.parentNode; } - if (highlightSpan) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - if (window.getSelection) { - window.getSelection().removeAllRanges(); - } - - var text=highlightSpan.textContent; - var rect=highlightSpan.getBoundingClientRect(); - var rawCfi=highlightSpan.getAttribute('data-cfi'); - - if ( !rawCfi) { - var cfiResult=window.getCfiPathForElement(highlightSpan, 0); - rawCfi=cfiResult.cfi; - } - - var cfiToReport=rawCfi; - - if (rawCfi && rawCfi.includes('|')) { - var cfiParts=rawCfi.split('|'); - cfiToReport=cfiParts[cfiParts.length - 1]; - console.log("HandleInteraction: Multi-CFI detected on single span. Reporting top layer: " + cfiToReport); - } - - if (window.HighlightBridge) { - window.HighlightBridge.onHighlightClicked(cfiToReport, - text, - rect.left, - rect.top, - rect.right, - rect.bottom); - } - - return true; + if (isHighlight) { + highlightSpan = target; + break; } - return false; + target = target.parentNode; } - // 1. Handle Taps (Click) - document.addEventListener('click', function (e) { - handleHighlightInteraction(e); + if (highlightSpan) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + if (window.getSelection) { + window.getSelection().removeAllRanges(); } - , true); + var text = highlightSpan.textContent; + var rect = highlightSpan.getBoundingClientRect(); + var rawCfi = highlightSpan.getAttribute("data-cfi"); - // 2. Handle Long Press (Context Menu) - "Atomic" Behavior - // This prevents the native Android selection handles from appearing inside the highlight - document.addEventListener('contextmenu', function (e) { - if (handleHighlightInteraction(e)) { - e.preventDefault(); // Ensure menu doesn't show - return false; - } + if (!rawCfi) { + var cfiResult = window.getCfiPathForElement(highlightSpan, 0); + rawCfi = cfiResult.cfi; } - , true); + var cfiToReport = rawCfi; - window.updateReaderStyles=function (fontSizeEm, lineHeight, fontFamily, textAlign) { - var logTag="ReaderFontDiagnosis"; - console.log(logTag + ": updateReaderStyles called. Size: " + fontSizeEm + ", LineHeight: " + lineHeight + ", Font: '" + fontFamily + "', Align: '" + textAlign + "'"); - - var dynamicStyleId='dynamicReaderStyles'; - var dynamicStyleElement=document.getElementById(dynamicStyleId); - - if ( !dynamicStyleElement) { - dynamicStyleElement=document.createElement('style'); - dynamicStyleElement.setAttribute('id', dynamicStyleId); - document.head.appendChild(dynamicStyleElement); + if (rawCfi && rawCfi.includes("|")) { + var cfiParts = rawCfi.split("|"); + cfiToReport = cfiParts[cfiParts.length - 1]; + console.log("HandleInteraction: Multi-CFI detected on single span. Reporting top layer: " + cfiToReport); } - var newFontSize=parseFloat(fontSizeEm); - var newLineHeight=parseFloat(lineHeight); - - if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize=1.0; - if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight=1.6; - - var fontCss=""; - var selector="body"; - - if (fontFamily && fontFamily !=="Original" && fontFamily !=="") { - var fallback="sans-serif"; - - if (fontFamily==="Merriweather" || fontFamily==="Lora") { - fallback="serif"; - } - - else if (fontFamily==="Roboto Mono") { - fallback="monospace"; - } - - selector="body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th"; - fontCss="font-family: '" + fontFamily + "', " + fallback + " !important;"; + if (window.HighlightBridge) { + window.HighlightBridge.onHighlightClicked(cfiToReport, text, rect.left, rect.top, rect.right, rect.bottom); } - // --- ALIGNMENT LOGIC --- - var alignCss=""; - var alignSelector="body, p, li, div, h1, h2, h3, h4, h5, h6"; + return true; + } - if (textAlign==="left") { - alignCss=` ` + alignSelector + ` { + return false; + } + + // 1. Handle Taps (Click) + document.addEventListener( + "click", + function (e) { + handleHighlightInteraction(e); + }, + + true, + ); + + // 2. Handle Long Press (Context Menu) - "Atomic" Behavior + // This prevents the native Android selection handles from appearing inside the highlight + document.addEventListener( + "contextmenu", + function (e) { + if (handleHighlightInteraction(e)) { + e.preventDefault(); // Ensure menu doesn't show + return false; + } + }, + + true, + ); + + window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign) { + var logTag = "ReaderFontDiagnosis"; + console.log( + logTag + + ": updateReaderStyles called. Size: " + + fontSizeEm + + ", LineHeight: " + + lineHeight + + ", Font: '" + + fontFamily + + "', Align: '" + + textAlign + + "'", + ); + + var dynamicStyleId = "dynamicReaderStyles"; + var dynamicStyleElement = document.getElementById(dynamicStyleId); + + if (!dynamicStyleElement) { + dynamicStyleElement = document.createElement("style"); + dynamicStyleElement.setAttribute("id", dynamicStyleId); + document.head.appendChild(dynamicStyleElement); + } + + var newFontSize = parseFloat(fontSizeEm); + var newLineHeight = parseFloat(lineHeight); + + if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0; + if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.6; + + var fontCss = ""; + var selector = "body"; + + if (fontFamily && fontFamily !== "Original" && fontFamily !== "") { + var fallback = "sans-serif"; + + if (fontFamily === "Merriweather" || fontFamily === "Lora") { + fallback = "serif"; + } else if (fontFamily === "Roboto Mono") { + fallback = "monospace"; + } + + selector = "body, p, span, div, li, a, h1, h2, h3, h4, h5, h6, blockquote, td, th"; + fontCss = "font-family: '" + fontFamily + "', " + fallback + " !important;"; + } + + // --- ALIGNMENT LOGIC --- + var alignCss = ""; + var alignSelector = "body, p, li, div, h1, h2, h3, h4, h5, h6"; + + if (textAlign === "left") { + alignCss = + ` ` + + alignSelector + + ` { text-align: left !important; } `; - } - - else if (textAlign==="justify") { - alignCss=` ` + alignSelector + ` { + } else if (textAlign === "justify") { + alignCss = + ` ` + + alignSelector + + ` { text-align: justify !important; -webkit-hyphens: auto !important; hyphens: auto !important; } `; + } + + dynamicStyleElement.innerHTML = + ` body { + font-size: ` + + newFontSize + + `em !important; + line-height: ` + + newLineHeight + + ` !important; } - dynamicStyleElement.innerHTML=` body { - font-size: ` + newFontSize + `em !important; - line-height: ` + newLineHeight + ` !important; + ` + + selector + + ` { + ` + + fontCss + + ` } - ` + selector + ` { - ` + fontCss + ` - } + ` + + alignCss + + ` `; - ` + alignCss + ` `; + setTimeout( + function () { + var computedBody = window.getComputedStyle(document.body).fontFamily; + console.log(logTag + ": [BODY] Computed font-family: " + computedBody); - setTimeout(function () { - var computedBody=window.getComputedStyle(document.body).fontFamily; - console.log(logTag + ": [BODY] Computed font-family: " + computedBody); + var firstPara = document.querySelector("p"); - var firstPara=document.querySelector('p'); + if (firstPara) { + var computedPara = window.getComputedStyle(firstPara).fontFamily; + var computedAlign = window.getComputedStyle(firstPara).textAlign; + console.log(logTag + ": [PARAGRAPH] Computed font-family: " + computedPara); + console.log(logTag + ": [PARAGRAPH] Inner Text Sample: " + firstPara.innerText.substring(0, 20)); + } else { + console.log(logTag + ": [PARAGRAPH] No

tag found to check."); + } - if (firstPara) { - var computedPara=window.getComputedStyle(firstPara).fontFamily; - var computedAlign=window.getComputedStyle(firstPara).textAlign; - console.log(logTag + ": [PARAGRAPH] Computed font-family: " + computedPara); - console.log(logTag + ": [PARAGRAPH] Inner Text Sample: " + firstPara.innerText.substring(0, 20)); + if (fontFamily && fontFamily !== "") { + var isCheckAvailable = document.fonts && document.fonts.check; + + if (isCheckAvailable) { + var loaded = document.fonts.check("12px '" + fontFamily + "'"); + console.log(logTag + ": Font Loading Status -> document.fonts.check('" + fontFamily + "') = " + loaded); + } else { + console.log(logTag + ": document.fonts API not available."); } + } + }, - else { - console.log(logTag + ": [PARAGRAPH] No

tag found to check."); - } + 300, + ); - if (fontFamily && fontFamily !=="") { - var isCheckAvailable=(document.fonts && document.fonts.check); + if (window.reportScrollState) { + setTimeout(window.reportScrollState, 60); + } + }; - if (isCheckAvailable) { - var loaded=document.fonts.check("12px '" + fontFamily + "'"); - console.log(logTag + ": Font Loading Status -> document.fonts.check('" + fontFamily + "') = " + loaded); + window.TOC_FRAGMENTS = window.TOC_FRAGMENTS || []; + + window.setTocFragments = function (jsonArray) { + console.log("FRAG_NAV_DEBUG: window.setTocFragments called with " + jsonArray.length + " items."); + window.TOC_FRAGMENTS = jsonArray; + // Immediate audit and report + window.auditTocFragments(); + window.reportScrollState(); + }; + + window.reportScrollState = function () { + if (typeof PageInfoReporter !== "undefined" && PageInfoReporter.updateScrollState) { + var scrollY = Math.round(window.scrollY || window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0); + var scrollHeight = Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)); + var clientHeight = Math.round(document.documentElement.clientHeight || window.innerHeight || 0); + + if (clientHeight === 0) return; + + var activeFragment = null; + var hasFoundAnyElementInDom = false; + + if (window.TOC_FRAGMENTS && window.TOC_FRAGMENTS.length > 0) { + // Adjust threshold to be slightly more forgiving (padding + 60px) + var threshold = window.VIEWPORT_PADDING_TOP + 60; + + for (var i = 0; i < window.TOC_FRAGMENTS.length; i++) { + var id = window.TOC_FRAGMENTS[i]; + // FIX: Look for both 'id' and 'name' attributes + var el = document.getElementById(id) || document.querySelector('[name="' + id + '"]'); + + if (el) { + hasFoundVisible = true; + var rect = el.getBoundingClientRect(); + + // Log individual element positions so we can see them in your FRAG_NAV_DEBUG filter + console.log("FRAG_NAV_DEBUG: Checking #" + id + " | rect.top: " + Math.round(rect.top) + " | threshold: " + threshold); + + if (rect.top <= threshold) { + activeFragment = id; + } else { + break; } - - else { - console.log(logTag + ": document.fonts API not available."); + } else { + if (!hasFoundVisible) { + activeFragment = id; } } } - - , 300); - - if (window.reportScrollState) { - setTimeout(window.reportScrollState, 60); } + + // Fallback for the very start of the chapter + if (activeFragment === null && scrollY < 50 && window.TOC_FRAGMENTS && window.TOC_FRAGMENTS.length > 0) { + activeFragment = window.TOC_FRAGMENTS[0]; + } + + PageInfoReporter.updateScrollState(scrollY, scrollHeight, clientHeight, activeFragment); } - ; + window.reportTopChunk(); + }; - window.TOC_FRAGMENTS=window.TOC_FRAGMENTS || []; + // ADD THIS NEW DIAGNOSTIC FUNCTION + window.auditTocFragments = function () { + console.log("FRAG_NAV_DEBUG: --- Starting DOM Audit ---"); - window.setTocFragments=function (jsonArray) { - console.log("FRAG_NAV_DEBUG: window.setTocFragments called with " + jsonArray.length + " items."); - window.TOC_FRAGMENTS=jsonArray; - // Immediate audit and report - window.auditTocFragments(); + if (!window.TOC_FRAGMENTS || window.TOC_FRAGMENTS.length === 0) { + console.log("FRAG_NAV_DEBUG: Audit failed - window.TOC_FRAGMENTS is empty."); + return; + } + + var foundCount = 0; + + window.TOC_FRAGMENTS.forEach((id) => { + var el = document.getElementById(id); + + if (el) { + foundCount++; + console.log("FRAG_NAV_DEBUG: [FOUND] ID: " + id + " | Tag: " + el.tagName + " | OffsetTop: " + el.offsetTop); + } else { + console.log("FRAG_NAV_DEBUG: [MISSING] ID: " + id + " - Not in DOM."); + } + }); + console.log("FRAG_NAV_DEBUG: Audit complete. Found " + foundCount + "/" + window.TOC_FRAGMENTS.length); + + // Also log some random IDs from the DOM to see what's actually there + var allWithId = document.querySelectorAll("[id]"); + console.log( + "FRAG_NAV_DEBUG: Sample IDs existing in DOM: " + + Array.from(allWithId) + .slice(0, 5) + .map((el) => el.id) + .join(", "), + ); + }; + + window.addEventListener("scroll", window.reportScrollState, { passive: true }); + window.addEventListener("resize", window.reportScrollState); + + window.triggerInitialScrollStateReport = function () { + var attempts = 0; + var maxAttempts = 7; + var baseInterval = 100; + + function tryReport() { + attempts++; window.reportScrollState(); - } + var currentClientHeight = document.documentElement.clientHeight || window.innerHeight || 0; - ; - - - window.reportScrollState=function () { - if (typeof PageInfoReporter !=='undefined' && PageInfoReporter.updateScrollState) { - var scrollY=Math.round(window.scrollY || window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0); - var scrollHeight=Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)); - var clientHeight=Math.round(document.documentElement.clientHeight || window.innerHeight || 0); - - if (clientHeight===0) return; - - var activeFragment=null; - var hasFoundAnyElementInDom=false; - - if (window.TOC_FRAGMENTS && window.TOC_FRAGMENTS.length > 0) { - // Adjust threshold to be slightly more forgiving (padding + 60px) - var threshold=window.VIEWPORT_PADDING_TOP + 60; - - for (var i=0; i < window.TOC_FRAGMENTS.length; i++) { - var id=window.TOC_FRAGMENTS[i]; - // FIX: Look for both 'id' and 'name' attributes - var el=document.getElementById(id) || document.querySelector('[name="' + id + '"]'); - - if (el) { - hasFoundVisible=true; - var rect=el.getBoundingClientRect(); - - // Log individual element positions so we can see them in your FRAG_NAV_DEBUG filter - console.log("FRAG_NAV_DEBUG: Checking #" + id + " | rect.top: " + Math.round(rect.top) + " | threshold: " + threshold); - - if (rect.top <=threshold) { - activeFragment=id; - } - - else { - break; - } - } - - else { - if ( !hasFoundVisible) { - activeFragment=id; - } - } - } - } - - // Fallback for the very start of the chapter - if (activeFragment===null && scrollY < 50 && window.TOC_FRAGMENTS && window.TOC_FRAGMENTS.length > 0) { - activeFragment=window.TOC_FRAGMENTS[0]; - } - - PageInfoReporter.updateScrollState(scrollY, scrollHeight, clientHeight, activeFragment); - } - - window.reportTopChunk(); - } - - ; - - // ADD THIS NEW DIAGNOSTIC FUNCTION - window.auditTocFragments=function () { - console.log("FRAG_NAV_DEBUG: --- Starting DOM Audit ---"); - - if ( !window.TOC_FRAGMENTS || window.TOC_FRAGMENTS.length===0) { - console.log("FRAG_NAV_DEBUG: Audit failed - window.TOC_FRAGMENTS is empty."); + if (currentClientHeight > 0) { + setTimeout(window.reportScrollState, 50); return; } - var foundCount=0; - - window.TOC_FRAGMENTS.forEach(id=> { - var el=document.getElementById(id); - - if (el) { - foundCount++; - console.log("FRAG_NAV_DEBUG: [FOUND] ID: " + id + " | Tag: " + el.tagName + " | OffsetTop: " + el.offsetTop); - } - - else { - console.log("FRAG_NAV_DEBUG: [MISSING] ID: " + id + " - Not in DOM."); - } - }); - console.log("FRAG_NAV_DEBUG: Audit complete. Found " + foundCount + "/" + window.TOC_FRAGMENTS.length); - - // Also log some random IDs from the DOM to see what's actually there - var allWithId=document.querySelectorAll('[id]'); - console.log("FRAG_NAV_DEBUG: Sample IDs existing in DOM: " + Array.from(allWithId).slice(0, 5).map(el=> el.id).join(", ")); - } - - ; - - window.addEventListener('scroll', window.reportScrollState, { - passive: true - }); - window.addEventListener('resize', window.reportScrollState); - - window.triggerInitialScrollStateReport=function () { - var attempts=0; var maxAttempts=7; var baseInterval=100; - - function tryReport() { - attempts++; window.reportScrollState(); - var currentClientHeight=document.documentElement.clientHeight || window.innerHeight || 0; - - if (currentClientHeight > 0) { - setTimeout(window.reportScrollState, 50); return; - } - if (attempts < maxAttempts) { - var retryDelay=baseInterval + (attempts * 50); setTimeout(tryReport, retryDelay); + var retryDelay = baseInterval + attempts * 50; + setTimeout(tryReport, retryDelay); } } setTimeout(tryReport, baseInterval); - } + }; - ; - - window.scrollToChapterStart=function () { + window.scrollToChapterStart = function () { requestAnimationFrame(function () { - window.scrollTo(0, 0); + window.scrollTo(0, 0); - setTimeout(function () { - window.reportScrollState(); - } + setTimeout( + function () { + window.reportScrollState(); + }, - , 100); - }); - } + 100, + ); + }); + }; - ; - - window.scrollToChapterEnd=function () { + window.scrollToChapterEnd = function () { requestAnimationFrame(function () { - var targetScrollY=(document.body.scrollHeight || document.documentElement.scrollHeight) - (window.innerHeight || document.documentElement.clientHeight); - if (targetScrollY < 0) targetScrollY=0; - window.scrollTo(0, targetScrollY); + var targetScrollY = + (document.body.scrollHeight || document.documentElement.scrollHeight) - (window.innerHeight || document.documentElement.clientHeight); + if (targetScrollY < 0) targetScrollY = 0; + window.scrollTo(0, targetScrollY); - setTimeout(function () { - window.reportScrollState(); - } + setTimeout( + function () { + window.reportScrollState(); + }, - , 100); - }); - } + 100, + ); + }); + }; - ; - - window.scrollToSpecificY=function (yPosition) { - if (typeof yPosition==='number' && yPosition >=0) { + window.scrollToSpecificY = function (yPosition) { + if (typeof yPosition === "number" && yPosition >= 0) { window.scrollTo(0, yPosition); - setTimeout(function () { + setTimeout( + function () { window.reportScrollState(); - } + }, - , 100); - } - - else { - setTimeout(function () { + 100, + ); + } else { + setTimeout( + function () { window.reportScrollState(); - } + }, - , 100); + 100, + ); } - } - - ; + }; function initializeReaderContent() { applyMobileOptimizationsAndSelection(); } - if (document.readyState==='complete' || document.readyState==='interactive') { + if (document.readyState === "complete" || document.readyState === "interactive") { initializeReaderContent(); + } else { + document.addEventListener("DOMContentLoaded", initializeReaderContent); } - else { - document.addEventListener('DOMContentLoaded', initializeReaderContent); - } + window.clearSearchHighlights = function () { + document.querySelectorAll("mark.search-highlight").forEach(function (el) { + var parent = el.parentNode; - window.clearSearchHighlights=function () { - document.querySelectorAll('mark.search-highlight').forEach(function (el) { - var parent=el.parentNode; - - if (parent) { - while (el.firstChild) { - parent.insertBefore(el.firstChild, el); - } - - parent.removeChild(el); - parent.normalize(); + if (parent) { + while (el.firstChild) { + parent.insertBefore(el.firstChild, el); } - }); + + parent.removeChild(el); + parent.normalize(); + } + }); return "JS: Search highlights cleared."; - } + }; - ; - - window.highlightAllOccurrences=function (query) { + window.highlightAllOccurrences = function (query) { window.clearSearchHighlights(); - if ( !query || query.length < 2) return "JS: Query too short for highlighting."; + if (!query || query.length < 2) return "JS: Query too short for highlighting."; - var walker=document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - var nodesToModify=[]; + var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + var nodesToModify = []; - while (node=walker.nextNode()) { + while ((node = walker.nextNode())) { if (node.nodeValue.toLowerCase().includes(query.toLowerCase())) { nodesToModify.push(node); } } - var regex = new RegExp('(' + query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') + ')', 'gi'); + var regex = new RegExp("(" + query.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&") + ")", "gi"); nodesToModify.forEach(function (textNode) { - if (textNode.parentNode && textNode.parentNode.tagName !=='SCRIPT' && textNode.parentNode.tagName !=='STYLE') { - var tempDiv=document.createElement('div'); - tempDiv.innerHTML=textNode.nodeValue.replace(regex, '$1'); + if (textNode.parentNode && textNode.parentNode.tagName !== "SCRIPT" && textNode.parentNode.tagName !== "STYLE") { + var tempDiv = document.createElement("div"); + tempDiv.innerHTML = textNode.nodeValue.replace(regex, '$1'); - var parent=textNode.parentNode; + var parent = textNode.parentNode; - while (tempDiv.firstChild) { - parent.insertBefore(tempDiv.firstChild, textNode); + while (tempDiv.firstChild) { + parent.insertBefore(tempDiv.firstChild, textNode); + } + + parent.removeChild(textNode); + } + }); + + return "JS: Highlighted " + document.querySelectorAll("mark.search-highlight").length + " occurrences."; + }; + + window.scrollToOccurrence = function (index) { + var highlights = document.querySelectorAll("mark.search-highlight"); + + if (highlights && index >= 0 && index < highlights.length) { + var element = highlights[index]; + + element.scrollIntoView({ behavior: "auto", block: "center", inline: "nearest" }); + return "JS: Scrolled to occurrence " + index; + } + + return "JS: Occurrence " + index + " not found."; + }; + + window.removeHighlight = function () { + var highlightNode; + var removedCount = 0; + + while ((highlightNode = document.querySelector(".tts-highlight")) !== null) { + var parent = highlightNode.parentNode; + + if (parent) { + try { + while (highlightNode.firstChild) { + parent.insertBefore(highlightNode.firstChild, highlightNode); } - parent.removeChild(textNode); + parent.removeChild(highlightNode); + parent.normalize(); + removedCount++; + } catch (e) { + if (highlightNode.parentNode) { + // Check if it wasn't already removed by a concurrent process + highlightNode.remove(); // Fallback removal + } + + break; // Exit loop on error to prevent infinite loop on a problematic node } - }); - - return "JS: Highlighted " + document.querySelectorAll('mark.search-highlight').length + " occurrences."; - } - - ; - - window.scrollToOccurrence=function (index) { - var highlights=document.querySelectorAll('mark.search-highlight'); - - if (highlights && index >=0 && index < highlights.length) { - var element=highlights[index]; - - element.scrollIntoView({ - behavior: 'auto', block: 'center', inline: 'nearest' - }); - return "JS: Scrolled to occurrence " + index; - } - - return "JS: Occurrence " + index + " not found."; -} - -; - -window.removeHighlight=function () { - var highlightNode; - var removedCount=0; - - while ((highlightNode=document.querySelector('.tts-highlight')) !==null) { - var parent=highlightNode.parentNode; - - if (parent) { - try { - while (highlightNode.firstChild) { - parent.insertBefore(highlightNode.firstChild, highlightNode); + } else { + try { + highlightNode.remove(); + removedCount++; + } catch (e_orphan) { + // console.error("JS: Error removing orphaned highlight node: " + e_orphan.message, highlightNode); } - parent.removeChild(highlightNode); - parent.normalize(); - removedCount++; - } - - catch (e) { - if (highlightNode.parentNode) { - // Check if it wasn't already removed by a concurrent process - highlightNode.remove(); // Fallback removal - } - - break; // Exit loop on error to prevent infinite loop on a problematic node + break; } } + }; - else { - try { - highlightNode.remove(); - removedCount++; - } + const TTS_HIGHLIGHT_LOG_TAG = "TTS_HIGHLIGHT_DIAGNOSIS"; - catch (e_orphan) { - // console.error("JS: Error removing orphaned highlight node: " + e_orphan.message, highlightNode); - } - - break; - } - } -} - -; - -const TTS_HIGHLIGHT_LOG_TAG="TTS_HIGHLIGHT_DIAGNOSIS"; - -window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { - console.log(`$ { + window.highlightFromCfi = function (cfi, textToHighlight, startOffset) { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -778,12 +769,12 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { } , Text='${textToHighlight.substring(0, 50)}...' `); - window.removeHighlight(); + window.removeHighlight(); - if ( !cfi || !textToHighlight) { - const errorMsg="JS: CFI or text missing."; + if (!cfi || !textToHighlight) { + const errorMsg = "JS: CFI or text missing."; - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -792,21 +783,21 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { } `); - return errorMsg; - } + return errorMsg; + } - try { - console.log(`$ { + try { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } : Resolving CFI to node...`); - const location=window.getNodeAndOffsetFromCfi(cfi); + const location = window.getNodeAndOffsetFromCfi(cfi); - if ( !location || !location.node) { - const errorMsg="JS: Could not find node for CFI."; + if (!location || !location.node) { + const errorMsg = "JS: Could not find node for CFI."; - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -815,10 +806,10 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { } `); - return errorMsg; - } + return errorMsg; + } - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -828,25 +819,24 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { , Text content: '${(location.node.textContent || "").substring(0, 50)}...' `); + const baseNode = location.node; + let remainingOffset = startOffset; - const baseNode=location.node; - let remainingOffset=startOffset; + const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + treeWalker.currentNode = baseNode; - const treeWalker=document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - treeWalker.currentNode=baseNode; + let currentNode = baseNode.nodeType === Node.TEXT_NODE ? baseNode : treeWalker.nextNode(); - let currentNode=(baseNode.nodeType===Node.TEXT_NODE) ? baseNode : treeWalker.nextNode(); + // 1. Find the starting text node and character position + while (currentNode && remainingOffset >= currentNode.nodeValue.length) { + remainingOffset -= currentNode.nodeValue.length; + currentNode = treeWalker.nextNode(); + } - // 1. Find the starting text node and character position - while (currentNode && remainingOffset >=currentNode.nodeValue.length) { - remainingOffset -=currentNode.nodeValue.length; - currentNode=treeWalker.nextNode(); - } + if (!currentNode) { + const errorMsg = "JS: Text offset is out of bounds for the CFI node."; - if ( !currentNode) { - const errorMsg="JS: Text offset is out of bounds for the CFI node."; - - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -855,10 +845,10 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { } `); - return errorMsg; - } + return errorMsg; + } - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -868,19 +858,19 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { `); - const range=document.createRange(); - range.setStart(currentNode, remainingOffset); + const range = document.createRange(); + range.setStart(currentNode, remainingOffset); - // 2. Find the ending text node and character position - let remainingTextLength=textToHighlight.length; - let endNode=currentNode; - let endOffset=remainingOffset; - let sanityCheck=0; + // 2. Find the ending text node and character position + let remainingTextLength = textToHighlight.length; + let endNode = currentNode; + let endOffset = remainingOffset; + let sanityCheck = 0; - while (remainingTextLength > 0 && endNode && sanityCheck < 50) { - const availableLength=endNode.nodeValue.length - endOffset; + while (remainingTextLength > 0 && endNode && sanityCheck < 50) { + const availableLength = endNode.nodeValue.length - endOffset; - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -894,24 +884,22 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { `); - if (availableLength >=remainingTextLength) { - endOffset +=remainingTextLength; - remainingTextLength=0; + if (availableLength >= remainingTextLength) { + endOffset += remainingTextLength; + remainingTextLength = 0; + } else { + remainingTextLength -= availableLength; + // Important: We need a fresh walker starting from the endNode to find the *next* text node reliably + const nextNodeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + nextNodeWalker.currentNode = endNode; + endNode = nextNodeWalker.nextNode(); + endOffset = 0; // Start from the beginning of the next node + } + + sanityCheck++; } - else { - remainingTextLength -=availableLength; - // Important: We need a fresh walker starting from the endNode to find the *next* text node reliably - const nextNodeWalker=document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - nextNodeWalker.currentNode=endNode; - endNode=nextNodeWalker.nextNode(); - endOffset=0; // Start from the beginning of the next node - } - - sanityCheck++; - } - - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -921,42 +909,35 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { `); - - if (remainingTextLength > 0) { - console.log(`$ { + if (remainingTextLength > 0) { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } : Text to highlight was longer than found text nodes. Highlighting to end of last found node.`); - if (endNode) { - range.setEnd(endNode, endNode.nodeValue.length); + if (endNode) { + range.setEnd(endNode, endNode.nodeValue.length); + } else { + const lastKnownGoodNode = range.endContainer; + range.setEnd(lastKnownGoodNode, lastKnownGoodNode.nodeValue.length); + } + } else { + range.setEnd(endNode, endOffset); } - else { - const lastKnownGoodNode=range.endContainer; - range.setEnd(lastKnownGoodNode, lastKnownGoodNode.nodeValue.length); - } - } + const highlightSpan = document.createElement("span"); + highlightSpan.className = "tts-highlight"; - else { - range.setEnd(endNode, endOffset); - } - - const highlightSpan=document.createElement('span'); - highlightSpan.className='tts-highlight'; - - try { - console.log(`$ { + try { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } : Attempting to surround content with highlight span.`); - range.surroundContents(highlightSpan); - } - - catch (e) { - console.log(`$ { + range.surroundContents(highlightSpan); + } catch (e) { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -965,28 +946,23 @@ window.highlightFromCfi=function (cfi, textToHighlight, startOffset) { } `); - const contents=range.extractContents(); - highlightSpan.appendChild(contents); - range.insertNode(highlightSpan); - } + const contents = range.extractContents(); + highlightSpan.appendChild(contents); + range.insertNode(highlightSpan); + } - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } : Highlight successful. Scrolling into view.`); - highlightSpan.scrollIntoView({ - behavior: 'smooth', block: 'center', inline: 'nearest' - }); - return "JS: Highlight successful."; + highlightSpan.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }); + return "JS: Highlight successful."; + } catch (e) { + const errorMsg = "JS: Error during highlightFromCfi: " + e.message; -} - -catch (e) { - const errorMsg="JS: Error during highlightFromCfi: " + e.message; - - console.log(`$ { + console.log(`$ { TTS_HIGHLIGHT_LOG_TAG } @@ -995,260 +971,246 @@ catch (e) { } `); - return errorMsg; -} -} - -; - -window.extractTextWithCfiFromTop=function () { - try { - // 1. Find the element at the top of the viewport. - const viewportX=window.innerWidth / 2; - const viewportY=window.VIEWPORT_PADDING_TOP + 20; // A bit down from the very top edge - let topElement=document.elementFromPoint(viewportX, viewportY); - - if ( !topElement) { - // Fallback if nothing is found (e.g., blank space between elements) - topElement=document.body.querySelector('p, h1, h2, h3, h4, img, svg, table, li'); - if ( !topElement) return "[]"; // Chapter seems empty + return errorMsg; } + }; - // 2. Find its containing block-level element that we use for TTS. - const ttsNodeSelector='p, h1, h2, h3, h4, h5, h6, li, blockquote'; - let startBlock=topElement.closest(ttsNodeSelector); + window.extractTextWithCfiFromTop = function () { + try { + // 1. Find the element at the top of the viewport. + const viewportX = window.innerWidth / 2; + const viewportY = window.VIEWPORT_PADDING_TOP + 20; // A bit down from the very top edge + let topElement = document.elementFromPoint(viewportX, viewportY); - if ( !startBlock) { - // If the element itself isn't in a TTS block, fall back to the start of the chapter. - console.log("Could not find a starting TTS block. Falling back to full chapter."); - return window.extractTextWithCfi(); - } + if (!topElement) { + // Fallback if nothing is found (e.g., blank space between elements) + topElement = document.body.querySelector("p, h1, h2, h3, h4, img, svg, table, li"); + if (!topElement) return "[]"; // Chapter seems empty + } - // 3. Get all potential TTS nodes. - const allContentNodes=Array.from(document.body.querySelectorAll(ttsNodeSelector)); + // 2. Find its containing block-level element that we use for TTS. + const ttsNodeSelector = "p, h1, h2, h3, h4, h5, h6, li, blockquote"; + let startBlock = topElement.closest(ttsNodeSelector); - // 4. Find the index of our starting block. - const startIndex=allContentNodes.findIndex(node=> node===startBlock); + if (!startBlock) { + // If the element itself isn't in a TTS block, fall back to the start of the chapter. + console.log("Could not find a starting TTS block. Falling back to full chapter."); + return window.extractTextWithCfi(); + } - if (startIndex===-1) { - // Should be rare if startBlock was found, but as a safeguard: - console.log("Could not find the start block in the node list. Falling back to full chapter."); - return window.extractTextWithCfi(); - } + // 3. Get all potential TTS nodes. + const allContentNodes = Array.from(document.body.querySelectorAll(ttsNodeSelector)); - // 5. Slice the array and process it. - const nodesToProcess=allContentNodes.slice(startIndex); - const results=[]; + // 4. Find the index of our starting block. + const startIndex = allContentNodes.findIndex((node) => node === startBlock); - nodesToProcess.forEach(node=> { - const text=node.innerText ? node.innerText.trim() : ""; + if (startIndex === -1) { + // Should be rare if startBlock was found, but as a safeguard: + console.log("Could not find the start block in the node list. Falling back to full chapter."); + return window.extractTextWithCfi(); + } - if (text.length > 0 && node.offsetParent !==null) { + // 5. Slice the array and process it. + const nodesToProcess = allContentNodes.slice(startIndex); + const results = []; + + nodesToProcess.forEach((node) => { + const text = node.innerText ? node.innerText.trim() : ""; + + if (text.length > 0 && node.offsetParent !== null) { try { - const cfi=getCfiPathForElement(node, 0); + const cfi = getCfiPathForElement(node, 0); if (cfi) { - results.push({ - cfi: cfi, text: text - }); + results.push({ cfi: cfi, text: text }); + } + } catch (e) { + // ignore CFI generation errors for a single node } } + }); - catch (e) { - // ignore CFI generation errors for a single node - } - } - }); + return JSON.stringify(results); + } catch (e) { + // On any error, fall back to extracting everything to not break TTS completely. + return window.extractTextWithCfi(); + } + }; - return JSON.stringify(results); + window.extractTextWithCfi = function () { + const results = []; + const contentNodes = document.body.querySelectorAll("p, h1, h2, h3, h4, h5, h6, li, blockquote"); -} + contentNodes.forEach((node) => { + const text = node.innerText ? node.innerText.trim() : ""; -catch (e) { - // On any error, fall back to extracting everything to not break TTS completely. - return window.extractTextWithCfi(); -} -} - -; - -window.extractTextWithCfi=function () { - const results=[]; - const contentNodes=document.body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote'); - - contentNodes.forEach(node=> { - const text=node.innerText ? node.innerText.trim() : ""; - - if (text.length > 0 && node.offsetParent !==null) { + if (text.length > 0 && node.offsetParent !== null) { try { - const cfi=getCfiPathForElement(node, 0); + const cfi = getCfiPathForElement(node, 0); if (cfi) { - results.push({ - cfi: cfi, text: text - }); + results.push({ cfi: cfi, text: text }); + } + } catch (e) {} + } + }); + const jsonResult = JSON.stringify(results); + return jsonResult; + }; + + window.TtsBridgeHelper = { + extractAndRelayText: function () { + try { + const structuredTextJson = window.extractTextWithCfiFromTop(); + + if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) { + TtsBridge.onStructuredTextExtracted(structuredTextJson); + } + } catch (e) { + if (typeof TtsBridge !== "undefined" && TtsBridge.onStructuredTextExtracted) { + TtsBridge.onStructuredTextExtracted("[]"); } } + }, + }; - catch (e) {} - } - }); -const jsonResult=JSON.stringify(results); -return jsonResult; -} + window.reportTopChunk = function () { + if (typeof ProgressReporter === "undefined" || typeof ProgressReporter.updateTopChunk === "undefined") return; -; + const topElement = document.elementFromPoint(window.innerWidth / 2, window.VIEWPORT_PADDING_TOP + 1); -window.TtsBridgeHelper= { - extractAndRelayText: function () { - try { - const structuredTextJson=window.extractTextWithCfiFromTop(); + if (topElement) { + const chunkContainer = topElement.closest("[data-chunk-index]"); - if (typeof TtsBridge !=='undefined' && TtsBridge.onStructuredTextExtracted) { - TtsBridge.onStructuredTextExtracted(structuredTextJson); + if (chunkContainer) { + const chunkIndex = parseInt(chunkContainer.dataset.chunkIndex, 10); + + if (!isNaN(chunkIndex)) { + ProgressReporter.updateTopChunk(chunkIndex); + } + } else { + ProgressReporter.updateTopChunk(0); } } + }; - catch (e) { - if (typeof TtsBridge !=='undefined' && TtsBridge.onStructuredTextExtracted) { - TtsBridge.onStructuredTextExtracted("[]"); + window.AiBridgeHelper = { + extractAndRelayTextForSummarization: function () { + var textContent = ""; + + try { + var mainElement = document.querySelector('article, [role="main"], main, body'); + + if (mainElement) { + textContent = mainElement.innerText || mainElement.textContent || ""; + } else { + textContent = document.body.innerText || document.body.textContent || ""; + } + + if (typeof AiBridge !== "undefined" && AiBridge.onContentExtractedForSummarization) { + AiBridge.onContentExtractedForSummarization(textContent.trim()); + } + } catch (e) { + if (typeof AiBridge !== "undefined" && AiBridge.onContentExtractedForSummarization) { + AiBridge.onContentExtractedForSummarization(""); + } } - } - } -} + }, + }; -; + window.checkImagesForDiagnosis = function () { + const images = document.querySelectorAll("img, image"); // 'image' for SVG images + const logTag = "ImageDiagnosis"; + console.log(logTag + ": JS checkImagesForDiagnosis called. Found " + images.length + " image elements."); -window.reportTopChunk=function () { - if (typeof ProgressReporter==='undefined' || typeof ProgressReporter.updateTopChunk==='undefined') return; - - const topElement=document.elementFromPoint(window.innerWidth / 2, window.VIEWPORT_PADDING_TOP + 1); - - if (topElement) { - const chunkContainer=topElement.closest('[data-chunk-index]'); - - if (chunkContainer) { - const chunkIndex=parseInt(chunkContainer.dataset.chunkIndex, 10); - - if ( !isNaN(chunkIndex)) { - ProgressReporter.updateTopChunk(chunkIndex); - } - } - - else { - ProgressReporter.updateTopChunk(0); - } - } -} - -; - -window.AiBridgeHelper= { - extractAndRelayTextForSummarization: function () { - var textContent=""; - - try { - var mainElement=document.querySelector('article, [role="main"], main, body'); - - if (mainElement) { - textContent=mainElement.innerText || mainElement.textContent || ""; - } - - else { - textContent=document.body.innerText || document.body.textContent || ""; - } - - if (typeof AiBridge !=='undefined' && AiBridge.onContentExtractedForSummarization) { - AiBridge.onContentExtractedForSummarization(textContent.trim()); - } - } - - catch (e) { - if (typeof AiBridge !=='undefined' && AiBridge.onContentExtractedForSummarization) { - AiBridge.onContentExtractedForSummarization(""); - } - } - } -} - -; - -window.checkImagesForDiagnosis=function () { - const images=document.querySelectorAll('img, image'); // 'image' for SVG images - const logTag="ImageDiagnosis"; - console.log(logTag + ": JS checkImagesForDiagnosis called. Found " + images.length + " image elements."); - - images.forEach((img, index)=> { - const src=img.src || img.getAttribute('xlink:href'); + images.forEach((img, index) => { + const src = img.src || img.getAttribute("xlink:href"); console.log(logTag + ": Image #" + index + " | src: '" + src + "'"); function processImage() { console.log(logTag + ": Processing Image #" + index + " (complete=" + img.complete + ")"); console.log(logTag + ": Image #" + index + " | clientWidth: " + img.clientWidth + ", clientHeight: " + img.clientHeight); console.log(logTag + ": Image #" + index + " | naturalWidth: " + img.naturalWidth + ", naturalHeight: " + img.naturalHeight); - console.log(logTag + ": Image #" + index + " | is visible (offsetParent): " + (img.offsetParent !==null)); + console.log(logTag + ": Image #" + index + " | is visible (offsetParent): " + (img.offsetParent !== null)); - const style=window.getComputedStyle(img); - console.log(logTag + ": Image #" + index + " | computed display: '" + style.display + "', visibility: '" + style.visibility + "', opacity: '" + style.opacity + "'"); + const style = window.getComputedStyle(img); + console.log( + logTag + + ": Image #" + + index + + " | computed display: '" + + style.display + + "', visibility: '" + + style.visibility + + "', opacity: '" + + style.opacity + + "'", + ); // FIX: If height has collapsed, manually calculate and set it forcefully. - if (img.complete && img.naturalWidth > 0 && img.clientWidth > 0 && img.clientHeight===0) { + if (img.complete && img.naturalWidth > 0 && img.clientWidth > 0 && img.clientHeight === 0) { console.log(logTag + ": CORRECTING GEOMETRY for Image #" + index); - const parent=img.parentElement; + const parent = img.parentElement; if (parent) { - const parentStyle=window.getComputedStyle(parent); - console.log(logTag + ": Parent <" + parent.tagName + "> computed height: " + parentStyle.height + ", overflow: " + parentStyle.overflow); + const parentStyle = window.getComputedStyle(parent); + console.log( + logTag + + ": Parent <" + + parent.tagName + + "> computed height: " + + parentStyle.height + + ", overflow: " + + parentStyle.overflow, + ); // Force the parent's height to be determined by its content. This is crucial. - parent.style.setProperty('height', 'auto', 'important'); + parent.style.setProperty("height", "auto", "important"); } - const aspectRatio=img.naturalHeight / img.naturalWidth; - const correctHeight=img.clientWidth * aspectRatio; + const aspectRatio = img.naturalHeight / img.naturalWidth; + const correctHeight = img.clientWidth * aspectRatio; // Remove the conflicting max-height property and then set the explicit height. - img.style.setProperty('max-height', 'none', 'important'); - img.style.setProperty('height', correctHeight + 'px', 'important'); + img.style.setProperty("max-height", "none", "important"); + img.style.setProperty("height", correctHeight + "px", "important"); console.log(logTag + ": Corrective styles applied to Image #" + index + ". Verifying height after a short delay for reflow..."); // After applying styles, wait a moment for the browser to reflow the layout // before reporting the new height and updating the scroll state. - setTimeout(function () { + setTimeout( + function () { console.log(logTag + ": Verified height for Image #" + index + ": " + img.clientHeight + "px"); window.reportScrollState(); // Update scroll metrics now that the image has height - } + }, - , 150); + 150, + ); } - img.onerror=function () { + img.onerror = function () { console.log(logTag + ": ERROR: Image #" + index + " FAILED to load. Src was: '" + src + "'"); - } + }; - ; - - if (img.complete && img.naturalWidth===0) { - console.log(logTag + ": WARNING: Image #" + index + " is complete but has 0 naturalWidth, may indicate loading error. Src: '" + src + "'"); + if (img.complete && img.naturalWidth === 0) { + console.log( + logTag + ": WARNING: Image #" + index + " is complete but has 0 naturalWidth, may indicate loading error. Src: '" + src + "'", + ); } } if (img.complete) { processImage(); - } - - else { - img.onload=processImage; + } else { + img.onload = processImage; } }); -} + }; -; + const CFI_LOG_TAG = "CFI_DIAGNOSIS"; -const CFI_LOG_TAG="CFI_DIAGNOSIS"; - -function log(message) { - console.log(`$ { + function log(message) { + console.log(`$ { CFI_LOG_TAG } @@ -1257,46 +1219,48 @@ function log(message) { } `); -} + } -function resolveCfiPath(rootElement, path) { - log(`Attempting to resolve path '${path}' from root <$ { + function resolveCfiPath(rootElement, path) { + log(`Attempting to resolve path '${path}' from root <$ { rootElement.tagName } >`); - let currentNode=rootElement; - const steps=path.substring(1).split('/').map(Number); + let currentNode = rootElement; + const steps = path.substring(1).split("/").map(Number); - for (let i=0; i < steps.length; i++) { - const cfiIndex=steps[i]; + for (let i = 0; i < steps.length; i++) { + const cfiIndex = steps[i]; - if ( !currentNode) { - log(`Traversal failed: currentNode became null before step $ { + if (!currentNode) { + log(`Traversal failed: currentNode became null before step $ { i } (CFI index $ { cfiIndex }).`); - return null; - } + return null; + } - const elementChildren=Array.from(currentNode.childNodes).filter(node=> node.nodeType===Node.ELEMENT_NODE); - const childNodeIndex=(cfiIndex - 2) / 2; + const elementChildren = Array.from(currentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE); + const childNodeIndex = (cfiIndex - 2) / 2; - if (childNodeIndex >=0 && childNodeIndex < elementChildren.length) { - currentNode=elementChildren[childNodeIndex]; - } - - else { - const childrenTags=elementChildren.map(node=> `<$ { + if (childNodeIndex >= 0 && childNodeIndex < elementChildren.length) { + currentNode = elementChildren[childNodeIndex]; + } else { + const childrenTags = elementChildren + .map( + (node) => `<$ { node.tagName || "TEXT" } - >`).join(', '); + >`, + ) + .join(", "); - log(`Step $ { + log(`Step $ { i } @@ -1317,7 +1281,7 @@ function resolveCfiPath(rootElement, path) { ]`); - log(`Parent Node HTML at failure point (<$ { + log(`Parent Node HTML at failure point (<$ { currentNode.tagName } @@ -1326,26 +1290,26 @@ function resolveCfiPath(rootElement, path) { } ...`); // ADD THIS LINE - return null; // Path is invalid from this root + return null; // Path is invalid from this root + } } + + return currentNode; } - return currentNode; -} - -window.getNodeAndOffsetFromCfi=function (cfi) { - log(`getNodeAndOffsetFromCfi called with: $ { + window.getNodeAndOffsetFromCfi = function (cfi) { + log(`getNodeAndOffsetFromCfi called with: $ { cfi } `); - try { - var pathParts=cfi.split(':'); - var nodePath=pathParts[0]; - var charOffset=pathParts.length > 1 ? parseInt(pathParts[1], 10) : 0; + try { + var pathParts = cfi.split(":"); + var nodePath = pathParts[0]; + var charOffset = pathParts.length > 1 ? parseInt(pathParts[1], 10) : 0; - log(`Parsed CFI: path=$ { + log(`Parsed CFI: path=$ { nodePath } @@ -1355,18 +1319,18 @@ window.getNodeAndOffsetFromCfi=function (cfi) { `); - let cfiRoot=document.getElementById('content-container') || document.body; - let pathToResolve=nodePath; + let cfiRoot = document.getElementById("content-container") || document.body; + let pathToResolve = nodePath; - const firstChunk=cfiRoot.querySelector('[data-chunk-index]'); + const firstChunk = cfiRoot.querySelector("[data-chunk-index]"); - if (firstChunk && pathToResolve.startsWith ('/4/')) { - log(`Paginator CFI detected. Adjusting root to first chunk and stripping '/4' from path.`); - cfiRoot=firstChunk; - pathToResolve='/' + pathToResolve.substring(3); - } + if (firstChunk && pathToResolve.startsWith("/4/")) { + log(`Paginator CFI detected. Adjusting root to first chunk and stripping '/4' from path.`); + cfiRoot = firstChunk; + pathToResolve = "/" + pathToResolve.substring(3); + } - log(`CFI Root is <$ { + log(`CFI Root is <$ { cfiRoot.tagName } @@ -1376,88 +1340,76 @@ window.getNodeAndOffsetFromCfi=function (cfi) { `); - let resolvedNode=resolveCfiPath(cfiRoot, pathToResolve); + let resolvedNode = resolveCfiPath(cfiRoot, pathToResolve); - log(`Resolution attempt #1 (from CFI root) result: $ { + log(`Resolution attempt #1 (from CFI root) result: $ { resolvedNode ? resolvedNode.tagName : 'null' } `); - if ( !resolvedNode) { - log("Resolution failed from all roots."); - return null; - } + if (!resolvedNode) { + log("Resolution failed from all roots."); + return null; + } - let currentNode=resolvedNode; + let currentNode = resolvedNode; - log(`Successfully resolved containing element: <$ { + log(`Successfully resolved containing element: <$ { currentNode.tagName || 'TEXT_NODE' } >`); - if (currentNode.nodeType===Node.ELEMENT_NODE) { - const treeWalker=document.createTreeWalker(currentNode, NodeFilter.SHOW_TEXT, null, false); - const firstTextNode=treeWalker.nextNode(); + if (currentNode.nodeType === Node.ELEMENT_NODE) { + const treeWalker = document.createTreeWalker(currentNode, NodeFilter.SHOW_TEXT, null, false); + const firstTextNode = treeWalker.nextNode(); - if (firstTextNode) { - log(`Found first text node inside element to apply offset.`); - currentNode=firstTextNode; + if (firstTextNode) { + log(`Found first text node inside element to apply offset.`); + currentNode = firstTextNode; + } else { + log(`Could not find a text node inside the target element. Using the element itself.`); + } } - else { - log(`Could not find a text node inside the target element. Using the element itself.`); - } - } - - return { - node: currentNode, offset: charOffset - } - - ; - } - - catch (e) { - log(`ERROR in getNodeAndOffsetFromCfi: $ { + return { node: currentNode, offset: charOffset }; + } catch (e) { + log(`ERROR in getNodeAndOffsetFromCfi: $ { e.message } `); - return null; - } -} + return null; + } + }; -; + window.getCfiPathForElement = function (element, charOffset) { + const logStack = []; -window.getCfiPathForElement=function (element, charOffset) { - const logStack=[]; + try { + var path = []; + var currentNode = element; - try { - var path=[]; - var currentNode=element; + if (currentNode.nodeType === Node.TEXT_NODE) { + logStack.push(`Initial node is a TEXT_NODE. Calculating cumulative offset.`); - if (currentNode.nodeType===Node.TEXT_NODE) { - logStack.push(`Initial node is a TEXT_NODE. Calculating cumulative offset.`); + // --- FIX: Accumulate offsets from previous siblings --- + var accumulatedOffset = charOffset || 0; + var sibling = currentNode.previousSibling; - // --- FIX: Accumulate offsets from previous siblings --- - var accumulatedOffset=charOffset || 0; - var sibling=currentNode.previousSibling; + while (sibling) { + if (sibling.nodeType === Node.TEXT_NODE) { + accumulatedOffset += sibling.nodeValue.length; + } else if (sibling.nodeType === Node.ELEMENT_NODE) { + // Elements like , contribute text content to the flow + accumulatedOffset += (sibling.textContent || "").length; + } - while (sibling) { - if (sibling.nodeType===Node.TEXT_NODE) { - accumulatedOffset +=sibling.nodeValue.length; + sibling = sibling.previousSibling; } - else if (sibling.nodeType===Node.ELEMENT_NODE) { - // Elements like , contribute text content to the flow - accumulatedOffset +=(sibling.textContent || "").length; - } - - sibling=sibling.previousSibling; - } - - logStack.push(`Original offset: $ { + logStack.push(`Original offset: $ { charOffset } @@ -1466,45 +1418,49 @@ window.getCfiPathForElement=function (element, charOffset) { } `); - charOffset=accumulatedOffset; - // ----------------------------------------------------- + charOffset = accumulatedOffset; + // ----------------------------------------------------- - logStack.push(`Using its parent <$ { + logStack.push(`Using its parent <$ { currentNode.parentNode.tagName } > for path generation.`); - currentNode=currentNode.parentNode; - } + currentNode = currentNode.parentNode; + } - const root=document.getElementById('content-container') || document.body; + const root = document.getElementById("content-container") || document.body; - logStack.push(`Using <$ { + logStack.push(`Using <$ { root.tagName } id='${root.id}' class='${root.className}' > as the consistent CFI root.`); - while (currentNode && currentNode !==root && currentNode.parentNode) { - const parentNode=currentNode.parentNode; - const elementSiblings=Array.from(parentNode.childNodes).filter(node=> node.nodeType===Node.ELEMENT_NODE); - const nodeIndex=elementSiblings.indexOf(currentNode); + while (currentNode && currentNode !== root && currentNode.parentNode) { + const parentNode = currentNode.parentNode; + const elementSiblings = Array.from(parentNode.childNodes).filter((node) => node.nodeType === Node.ELEMENT_NODE); + const nodeIndex = elementSiblings.indexOf(currentNode); - if (nodeIndex===-1) { - currentNode=parentNode; - continue; - } + if (nodeIndex === -1) { + currentNode = parentNode; + continue; + } - const cfiIndex=(nodeIndex * 2) + 2; - path.unshift(cfiIndex); + const cfiIndex = nodeIndex * 2 + 2; + path.unshift(cfiIndex); - const childrenTags=elementSiblings.map(node=> `<$ { + const childrenTags = elementSiblings + .map( + (node) => `<$ { node.tagName || "TEXT" } - >`).join(', '); + >`, + ) + .join(", "); - logStack.push(`GENERATION: Parent <$ { + logStack.push(`GENERATION: Parent <$ { parentNode.tagName } @@ -1534,468 +1490,337 @@ window.getCfiPathForElement=function (element, charOffset) { `); - currentNode=parentNode; - } + currentNode = parentNode; + } - var cfi=`/` + path.join('/'); + var cfi = `/` + path.join("/"); - if (charOffset !==undefined && charOffset > 0) { - cfi +=':' + charOffset; - } + if (charOffset !== undefined && charOffset > 0) { + cfi += ":" + charOffset; + } - logStack.push(`Final CFI generated: $ { + logStack.push(`Final CFI generated: $ { cfi } `); - return { - cfi: cfi, log: logStack - } - - ; - } - - catch (e) { - logStack.push(`ERROR in getCfiPathForElement: $ { + return { cfi: cfi, log: logStack }; + } catch (e) { + logStack.push(`ERROR in getCfiPathForElement: $ { e.message } `); - return { - cfi: `/2`, log: logStack + return { cfi: `/2`, log: logStack }; } + }; - ; - } -} + window.getCurrentCfi = function() { + const debugLog = []; + let finalCfi = "/2"; // Fallback to root -; + function logCfi(m) { debugLog.push(m); } -window.getCurrentCfi=function () { - const debugLog=[]; - let finalCfi="/2"; + try { + const viewportX = window.innerWidth / 2; + // Probe slightly further down to avoid headers/padding issues + const viewportY = window.VIEWPORT_PADDING_TOP + 50; - try { - const viewportX=window.innerWidth / 2; - const viewportY=window.VIEWPORT_PADDING_TOP + 5; + logCfi("Probing for CFI at " + viewportX + "," + viewportY); - debugLog.push(`Probing for CFI at coordinates: x=$ { - viewportX - } + let topElement = document.elementFromPoint(viewportX, viewportY); - , y=$ { - viewportY - } + // FIX: Ignore empty chunk containers or the container wrapper itself + if (topElement && (topElement.id === 'content-container' || + (topElement.classList && topElement.classList.contains('chunk-container') && topElement.innerText.trim() === ""))) { - (padding top: $ { - window.VIEWPORT_PADDING_TOP - })`); - - let topElement=document.elementFromPoint(viewportX, viewportY); - - if ( !topElement) { - debugLog.push("elementFromPoint returned null. Trying to find the first element in the body as a fallback."); - topElement=document.body.querySelector('p, h1, h2, h3, h4, img, svg, table, li'); - - if ( !topElement) { - debugLog.push("No meaningful elements found in body. Aborting."); - - return JSON.stringify({ - cfi: finalCfi, log: debugLog - }); - } - } - - debugLog.push(`Initial element found: <$ { - topElement.tagName - } - - id='${topElement.id}' class='${topElement.className}' >`); - - debugLog.push(`Element's text content (first 50 chars): '$ { - (topElement.textContent || "").trim().substring(0, 50) - } - - '`); - - // Ensure we are inside our content root, not on the body or html itself - const contentRoot=document.getElementById('content-container') || document.body; - - if ( !contentRoot.contains(topElement)) { - topElement=contentRoot.querySelector('p, h1, h2, h3, h4, img, svg, table, li') || contentRoot.firstElementChild; - - debugLog.push(`Initial element was outside content root. Switched to first meaningful child: <$ { - topElement ? topElement.tagName : 'null' + logCfi("Hit empty chunk or container. scanning for first visible content..."); + // Fallback: Query specifically for content elements + const elements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, span, div.chunk-container'); + for (let i = 0; i < elements.length; i++) { + const el = elements[i]; + const rect = el.getBoundingClientRect(); + // Find first element that is effectively visible below the top padding + if (rect.bottom > window.VIEWPORT_PADDING_TOP && el.innerText.trim().length > 0) { + topElement = el; + logCfi("Found alternative content element: <" + el.tagName + ">"); + break; + } } - - >`); - } - - let range=document.caretRangeFromPoint ? document.caretRangeFromPoint(viewportX, viewportY) : null; - let nodeForCfi; - let offsetForCfi=0; - - if (range && range.startContainer && range.startContainer.nodeType===Node.TEXT_NODE && range.startContainer.textContent.trim().length > 0) { - nodeForCfi=range.startContainer; - offsetForCfi=range.startOffset; - - debugLog.push(`Success with caretRangeFromPoint. Node: "${nodeForCfi.nodeValue.substring(0, 30)}...", Offset: $ { - offsetForCfi - } - - `); - } - - else { - debugLog.push("caretRangeFromPoint failed or found non-text/empty node. Using element-based CFI."); - const treeWalker=document.createTreeWalker(topElement, NodeFilter.SHOW_TEXT, null, false); - let firstTextNode=treeWalker.nextNode(); - nodeForCfi=(firstTextNode && firstTextNode.textContent.trim().length > 0) ? firstTextNode : topElement; - offsetForCfi=0; - - debugLog.push(`Using fallback node for CFI generation: <$ { - nodeForCfi.tagName || 'TEXT_NODE' - } - - >`); - } - - const cfiResult=getCfiPathForElement(nodeForCfi, offsetForCfi); - finalCfi=cfiResult.cfi; - debugLog.push(...cfiResult.log); - - } - - catch (e) { - debugLog.push(`FATAL ERROR in getCurrentCfi: $ { - e.message } - `); - } - - return JSON.stringify({ - cfi: finalCfi, log: debugLog - }); -} - -; - -window.scrollToCfi=function (cfi) { - log(`scrollToCfi called with: $ { - cfi - } - - `); - let cleanCfi=cfi; - - if (cfi && cfi.includes('@')) { - log("Old CFI format detected. Stripping prefix."); - cleanCfi=cfi.substring(cfi.indexOf('@') + 1); - - log(`Cleaned CFI is now: $ { - cleanCfi + if (!topElement) { + logCfi("No element found. Fallback to body first child."); + topElement = document.body.firstElementChild; } - `); + if (!topElement) { + return JSON.stringify({ cfi: finalCfi, log: debugLog }); + } + + logCfi("Selected Reference Element: <" + topElement.tagName + "> ID:" + topElement.id); + + // Try caret range for precision + let range = null; + if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(viewportX, viewportY); + } + + let nodeForCfi; + let offsetForCfi = 0; + + if (range && range.startContainer && range.startContainer.nodeType === Node.TEXT_NODE) { + nodeForCfi = range.startContainer; + offsetForCfi = range.startOffset; + logCfi("Precision match via caretRangeFromPoint."); + } else { + // Walker fallback + const treeWalker = document.createTreeWalker(topElement, NodeFilter.SHOW_TEXT, null, false); + let firstTextNode = treeWalker.nextNode(); + nodeForCfi = (firstTextNode && firstTextNode.textContent.trim().length > 0) ? firstTextNode : topElement; + offsetForCfi = 0; + logCfi("Fallback match via TreeWalker."); + } + + const cfiResult = window.getCfiPathForElement(nodeForCfi, offsetForCfi); + finalCfi = cfiResult.cfi; + // Merge logs + if (cfiResult.log) debugLog.push(...cfiResult.log); + + } catch (e) { + debugLog.push("Error in getCurrentCfi: " + e.message); + } + + // We intentionally do not use TAG_BM here as this is called frequently on scroll. + // The CfiBridge in Kotlin logs this separately. + return JSON.stringify({ cfi: finalCfi, log: debugLog }); + }; + + const TAG_BM = "BookmarkDiagnosis"; + + function logBm(msg) { + console.log(TAG_BM + ": " + msg); } - if ( !cleanCfi || !cleanCfi.startsWith ('/')) { - log("CFI is null or has invalid format, not scrolling."); - return; - } + window.scrollToCfi = function(cfi) { + logBm("scrollToCfi called with: " + cfi); + let cleanCfi = cfi; - setTimeout(()=> { - log(`Executing scroll for CFI '${cleanCfi}' after delay.`); + if (cfi && cfi.includes('@')) { + cleanCfi = cfi.substring(cfi.indexOf('@') + 1); + } + + if (!cleanCfi || !cleanCfi.startsWith('/')) { + logBm("Invalid CFI format, aborting scroll."); + if (window.CfiBridge && window.CfiBridge.onScrollFinished) { + window.CfiBridge.onScrollFinished(false); + } + return; + } + + let attempts = 0; + const maxAttempts = 20; + + function attemptScroll() { + attempts++; + logBm("Scroll Attempt " + attempts + "/" + maxAttempts + " for " + cleanCfi); try { - const location=window.getNodeAndOffsetFromCfi(cleanCfi); + const location = window.getNodeAndOffsetFromCfi(cleanCfi); if (location && location.node) { - log(`Successfully found location for CFI. Node Type: $ { - location.node.nodeType - } + logBm("Target node FOUND. Node: " + location.node.nodeName); - , Node Name: $ { - location.node.nodeName - } + if (!document.body.contains(location.node)) { + logBm("Node found but detached. Retrying..."); + if (attempts < maxAttempts) setTimeout(attemptScroll, 100); + return; + } - `); - - if (location.node.nodeType===Node.TEXT_NODE && location.offset > 0) { + if (location.node.nodeType === Node.TEXT_NODE && location.offset > 0) { try { - const range=document.createRange(); - const validOffset=Math.min(location.offset, location.node.nodeValue.length); + const range = document.createRange(); + const validOffset = Math.min(location.offset, location.node.nodeValue.length); range.setStart(location.node, validOffset); range.collapse(true); + const rect = range.getBoundingClientRect(); - const rect=range.getBoundingClientRect(); - - if (rect.top !==0 || rect.left !==0) { - const currentScrollY=window.scrollY; - const targetScrollY=currentScrollY + rect.top - window.VIEWPORT_PADDING_TOP; - - log(`Precise scroll calculated. Rect top: $ { - rect.top + if (rect.top !== 0 || rect.bottom !== 0) { + const targetScrollY = window.scrollY + rect.top - window.VIEWPORT_PADDING_TOP; + window.scrollTo({ top: targetScrollY, behavior: 'auto' }); + setTimeout(() => { + window.reportScrollState(); + if (window.CfiBridge && window.CfiBridge.onScrollFinished) { + window.CfiBridge.onScrollFinished(true); } - - , Current scrollY: $ { - currentScrollY - } - - , Target scrollY: $ { - targetScrollY - } - - `); - - window.scrollTo({ - top: targetScrollY, behavior: 'auto' - }); - - setTimeout(window.reportScrollState, 150); - return; - } - - else { - log("Range rect.top was 0, indicating an issue or the element is already at the top. Falling back to element scroll."); - } - } - - catch (rangeError) { - log(`Error during precise scroll calculation: $ { - rangeError.message + }, 150); + return; } - - . Falling back.`); - } - } - - const targetElement=(location.node.nodeType===Node.TEXT_NODE) ? location.node.parentNode : location.node; - - log(`Using fallback scrollIntoView for element: <$ { - targetElement.tagName - } - - >`); - - targetElement.scrollIntoView({ - behavior: 'auto', block: 'start', inline: 'nearest' - }); - - setTimeout(()=> { - const newScrollY=window.scrollY; - - log(`ScrollY AFTER fallback scroll: $ { - newScrollY + } catch (e) { + logBm("Precise scroll failed: " + e.message); } + } - `); - window.reportScrollState(); + const targetElement = (location.node.nodeType === Node.TEXT_NODE) ? location.node.parentNode : location.node; + targetElement.scrollIntoView({ behavior: 'auto', block: 'start', inline: 'nearest' }); + + setTimeout(() => { + if (window.VIEWPORT_PADDING_TOP > 0) window.scrollBy(0, -window.VIEWPORT_PADDING_TOP); + window.reportScrollState(); + if (window.CfiBridge && window.CfiBridge.onScrollFinished) { + window.CfiBridge.onScrollFinished(true); + } + }, 150); + + } else { + if (attempts < maxAttempts) { + setTimeout(attemptScroll, 100); + } else { + logBm("Max attempts reached. Scroll failed."); + if (window.CfiBridge && window.CfiBridge.onScrollFinished) { + window.CfiBridge.onScrollFinished(false); + } + } + } + } catch (e) { + logBm("Fatal error: " + e.message); + if (window.CfiBridge && window.CfiBridge.onScrollFinished) { + window.CfiBridge.onScrollFinished(false); + } + } + } + + attemptScroll(); + }; + + window.getElementByCfi = function(cfi) { + logBm("getElementByCfi called with: " + cfi); + try { + const location = window.getNodeAndOffsetFromCfi(cfi); + if (location && location.node) { + logBm("Found node: " + location.node.nodeName); + return (location.node.nodeType === Node.TEXT_NODE) ? location.node.parentNode : location.node; + } + logBm("Node not found."); + return null; + } catch (e) { + logBm("Error: " + e.message); + return null; + } + }; + + window.isElementInViewport = function (el) { + if (!el || typeof el.getBoundingClientRect !== "function") return false; + const rect = el.getBoundingClientRect(); + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + return rect.top >= 0 && rect.top <= viewportHeight; + }; + + window.getSnippetForCfi = function (cfi) { + var TAG_DIAG = "BookmarkDiagnosis"; + console.log(TAG_DIAG + ": getSnippetForCfi called with CFI: " + cfi); + const location = window.getNodeAndOffsetFromCfi(cfi); + + if (location && location.node) { + let textNode = location.node.nodeType === Node.TEXT_NODE ? location.node : null; + let offset = location.offset; + + if (!textNode) { + const treeWalker = document.createTreeWalker(location.node, NodeFilter.SHOW_TEXT, null, false); + textNode = treeWalker.nextNode(); + offset = 0; + } + + if (textNode) { + const fullText = textNode.textContent; + const lastSpace = fullText.lastIndexOf(" ", offset); + const startIndex = lastSpace === -1 ? 0 : lastSpace + 1; + + let snippet = fullText.substring(startIndex); + + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + walker.currentNode = textNode; + + while (snippet.length < 160) { + const nextNode = walker.nextNode(); + + if (nextNode) { + snippet += " " + nextNode.textContent; + } else { + break; + } } - , 150); - - } - - else { - log("FAILED to find the target node for CFI: " + cleanCfi); - } - } - - catch (e) { - log("FATAL ERROR during scrollToCfi: " + e.message); - } -} - -, 250); -} - -; - -window.getElementByCfi=function (cfi) { - var TAG_DIAG="BookmarkDiagnosis"; - console.log(TAG_DIAG + ": getElementByCfi called with CFI: " + cfi); - - try { - var pathParts=cfi.split('!')[0].split(':')[0]; - var steps=pathParts.substring(1).split('/').map(Number); - console.log(TAG_DIAG + ": Parsed steps: " + JSON.stringify(steps)); - var currentNode=document.body; - - for (var i=1; i < steps.length; i++) { - if ( !currentNode) { - console.log(TAG_DIAG + ": Traversal failed. currentNode became null at step " + i); - return null; - } - - var cfiIndex=steps[i]; - - const children=Array.from(currentNode.childNodes).filter(node=> { - return node.nodeType===Node.ELEMENT_NODE || (node.nodeType===Node.TEXT_NODE && node.textContent.trim() !==''); - }); - - var childNodeIndex=(cfiIndex - 2) / 2; - console.log(TAG_DIAG + ": Step " + i + " (CFI index " + cfiIndex + "): Looking for child at index " + childNodeIndex + " among " + children.length + " children."); - - - if (childNodeIndex < 0 || childNodeIndex >=children.length) { - console.log(TAG_DIAG + ": Path is invalid. Child index " + childNodeIndex + " is out of bounds."); - return null; // Path is invalid for this document - } - - currentNode=children[childNodeIndex]; - - if (currentNode) { - console.log(TAG_DIAG + ": Found node for step " + i + ": " + (currentNode.tagName || "TEXT_NODE")); + const finalSnippet = snippet.trim().replace(/\s+/g, " ").substring(0, 150); + console.log(TAG_DIAG + ": Snippet generated: '" + finalSnippet + "'"); + return finalSnippet; } } - if ( !currentNode) { - console.log(TAG_DIAG + ": Final currentNode is null."); + console.log(TAG_DIAG + ": No usable text found for CFI. Returning empty snippet."); + return ""; + }; + + window.findFirstVisibleCfi = function (cfiArray) { + if (!Array.isArray(cfiArray)) { return null; } - var resultNode=(currentNode.nodeType===Node.TEXT_NODE) ? currentNode.parentNode : currentNode; - console.log(TAG_DIAG + ": Successfully found element: " + (resultNode ? resultNode.tagName : "null")); - return resultNode; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const activationZoneEnd = window.VIEWPORT_PADDING_TOP + (viewportHeight - window.VIEWPORT_PADDING_TOP) * 0.6; - } + for (const cfi of cfiArray) { + const location = window.getNodeAndOffsetFromCfi(cfi); - catch (e) { - console.log(TAG_DIAG + ": Error during getElementByCfi: " + e.message); + if (location && location.node) { + try { + const node = location.node; + // Use the element's rect for stability, not a potentially zero-height range rect. + const elementForRect = node.nodeType === Node.TEXT_NODE ? node.parentNode : node; + const rect = elementForRect.getBoundingClientRect(); + const nodeName = elementForRect.nodeName; + + // The element is "active" if: + // 1. It's positioned at the top of the content, even if under the padding. + const isAtContentTop = rect.top >= 0 && rect.top < window.VIEWPORT_PADDING_TOP; + // 2. Any part of it overlaps with the main "reading zone" below the padding. + const isInReadingZone = rect.bottom > window.VIEWPORT_PADDING_TOP && rect.top < activationZoneEnd; + + if ((isAtContentTop && rect.height > 0) || isInReadingZone) { + return cfi; + } + } catch (e) { + console.log(TAG + ": Error processing CFI " + cfi + ": " + e.message); + } + } else { + console.log(TAG + ": No node found for CFI: " + cfi); + } + } + + console.log(TAG + ": No visible bookmarked element found in viewport. Returning null."); return null; - } -} - -; - -window.isElementInViewport=function (el) { - if ( !el || typeof el.getBoundingClientRect !=='function') return false; - const rect=el.getBoundingClientRect(); - const viewportHeight=window.innerHeight || document.documentElement.clientHeight; - return (rect.top >=0 && rect.top <=viewportHeight); -} - -; - -window.getSnippetForCfi=function (cfi) { - var TAG_DIAG="BookmarkDiagnosis"; - console.log(TAG_DIAG + ": getSnippetForCfi called with CFI: " + cfi); - const location=window.getNodeAndOffsetFromCfi(cfi); - - if (location && location.node) { - let textNode=location.node.nodeType===Node.TEXT_NODE ? location.node : null; - let offset=location.offset; - - if ( !textNode) { - const treeWalker=document.createTreeWalker(location.node, NodeFilter.SHOW_TEXT, null, false); - textNode=treeWalker.nextNode(); - offset=0; - } - - if (textNode) { - const fullText=textNode.textContent; - const lastSpace=fullText.lastIndexOf(' ', offset); - const startIndex=(lastSpace===-1) ? 0 : lastSpace + 1; - - let snippet=fullText.substring(startIndex); - - const walker=document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - walker.currentNode=textNode; - - while (snippet.length < 160) { - const nextNode=walker.nextNode(); - - if (nextNode) { - snippet +=" " + nextNode.textContent; - } - - else { - break; - } - } - - const finalSnippet=snippet.trim().replace(/\s+/g, ' ').substring(0, 150); - console.log(TAG_DIAG + ": Snippet generated: '" + finalSnippet + "'"); - return finalSnippet; - } - } - - console.log(TAG_DIAG + ": No usable text found for CFI. Returning empty snippet."); - return ""; -} - -; - -window.findFirstVisibleCfi=function (cfiArray) { - - if ( !Array.isArray(cfiArray)) { - return null; - } - - const viewportHeight=window.innerHeight || document.documentElement.clientHeight; - const activationZoneEnd=window.VIEWPORT_PADDING_TOP + ((viewportHeight - window.VIEWPORT_PADDING_TOP) * 0.6); - - for (const cfi of cfiArray) { - const location=window.getNodeAndOffsetFromCfi(cfi); - - if (location && location.node) { - try { - const node=location.node; - // Use the element's rect for stability, not a potentially zero-height range rect. - const elementForRect=(node.nodeType===Node.TEXT_NODE) ? node.parentNode : node; - const rect=elementForRect.getBoundingClientRect(); - const nodeName=elementForRect.nodeName; - - // The element is "active" if: - // 1. It's positioned at the top of the content, even if under the padding. - const isAtContentTop=rect.top >=0 && rect.top < window.VIEWPORT_PADDING_TOP; - // 2. Any part of it overlaps with the main "reading zone" below the padding. - const isInReadingZone=rect.bottom > window.VIEWPORT_PADDING_TOP && rect.top < activationZoneEnd; - - if ((isAtContentTop && rect.height > 0) || isInReadingZone) { - return cfi; - } - } - - catch (e) { - console.log(TAG + ": Error processing CFI " + cfi + ": " + e.message); - } - } - - else { - console.log(TAG + ": No node found for CFI: " + cfi); - } - } - - console.log(TAG + ": No visible bookmarked element found in viewport. Returning null."); - return null; -} - -; - + }; })(); (function () { - // --- VIRTUALIZATION LOGIC --- - if (window.virtualization) return; + // --- VIRTUALIZATION LOGIC --- + if (window.virtualization) return; - let currentBottomChunkIndex=0; - let totalChunks=0; - let isLoading=false; - let observer; + let currentBottomChunkIndex = 0; + let totalChunks = 0; + let isLoading = false; + let observer; - window.virtualization= { - totalChunks: 0, - chunksData: [], - chunkHeights: [], - observer: null, + window.virtualization = { + totalChunks: 0, + chunksData: [], + chunkHeights: [], + observer: null, - init: function (initialChunkIndex, total) { - console.log(`Virtualization: Init with $ { + init: function (initialChunkIndex, total) { + console.log(`Virtualization: Init with $ { total } @@ -2004,104 +1829,97 @@ window.findFirstVisibleCfi=function (cfiArray) { } `); - this.totalChunks=total; - this.chunksData=new Array(total).fill(null); - this.chunkHeights=new Array(total).fill(0); + this.totalChunks = total; + this.chunksData = new Array(total).fill(null); + this.chunkHeights = new Array(total).fill(0); - const container=document.getElementById('content-container'); + const container = document.getElementById("content-container"); - if (container) { - container.querySelectorAll('.chunk-container').forEach(div=> { - let idx=parseInt(div.dataset.chunkIndex, 10); - let content=div.innerHTML.trim(); + if (container) { + container.querySelectorAll(".chunk-container").forEach((div) => { + let idx = parseInt(div.dataset.chunkIndex, 10); + let content = div.innerHTML.trim(); - if (content.length > 0) { - this.chunksData=content; - this.chunkHeights=div.getBoundingClientRect().height; - } - }); - } - - this.setupObserver(); + if (content.length > 0) { + this.chunksData = content; + this.chunkHeights = div.getBoundingClientRect().height; + } + }); } - , + this.setupObserver(); + }, - setupObserver: function () { - if (this.observer) this.observer.disconnect(); + setupObserver: function () { + if (this.observer) this.observer.disconnect(); - this.observer=new IntersectionObserver((entries)=> { - let scrollAdjust=0; + this.observer = new IntersectionObserver( + (entries) => { + let scrollAdjust = 0; - entries.forEach(entry=> { - let div=entry.target; - let idx=parseInt(div.dataset.chunkIndex, 10); + entries.forEach((entry) => { + let div = entry.target; + let idx = parseInt(div.dataset.chunkIndex, 10); - if (entry.isIntersecting) { - if ( !this.chunksData) { - if (window.ContentBridge && window.ContentBridge.requestChunk) { - window.ContentBridge.requestChunk(idx); - } - } - - else if (div.innerHTML==='') { - let oldHeight=div.getBoundingClientRect().height; - div.innerHTML=this.chunksData; - div.style.height=''; - let newHeight=div.getBoundingClientRect().height; - this.chunkHeights=newHeight; - - if (div.getBoundingClientRect().top < 0) { - scrollAdjust +=(newHeight - oldHeight); - } - } + if (entry.isIntersecting) { + if (!this.chunksData) { + if (window.ContentBridge && window.ContentBridge.requestChunk) { + window.ContentBridge.requestChunk(idx); } + } else if (div.innerHTML === "") { + let oldHeight = div.getBoundingClientRect().height; + div.innerHTML = this.chunksData; + div.style.height = ""; + let newHeight = div.getBoundingClientRect().height; + this.chunkHeights = newHeight; - else { - if (div.innerHTML !=='') { - let oldHeight=div.getBoundingClientRect().height; - this.chunkHeights=oldHeight; - div.style.height=oldHeight + 'px'; - div.innerHTML=''; - } + if (div.getBoundingClientRect().top < 0) { + scrollAdjust += newHeight - oldHeight; } - }); - - if (scrollAdjust !==0) { - window.scrollBy(0, scrollAdjust); + } + } else { + if (div.innerHTML !== "") { + let oldHeight = div.getBoundingClientRect().height; + this.chunkHeights = oldHeight; + div.style.height = oldHeight + "px"; + div.innerHTML = ""; + } } + }); + if (scrollAdjust !== 0) { + window.scrollBy(0, scrollAdjust); } + }, - , { - rootMargin: '2500px 0px' - }); + { rootMargin: "2500px 0px" }, + ); - document.querySelectorAll('.chunk-container').forEach(div=> { - this.observer.observe(div); - }); - } - - , + document.querySelectorAll(".chunk-container").forEach((div) => { + this.observer.observe(div); + }); + }, appendChunk: function (index, htmlContent) { - console.log(`Virtualization: Receiving chunk $ { - index + console.log(`Virtualization: Receiving chunk ${index} from Kotlin`); + + if (Array.isArray(this.chunksData)) { + this.chunksData[index] = htmlContent; + } + + let div = document.querySelector(`.chunk-container[data-chunk-index="${index}"]`); + + if (div && div.innerHTML === "") { + let oldHeight = div.getBoundingClientRect().height; + div.innerHTML = htmlContent; + div.style.height = ""; + + let newHeight = div.getBoundingClientRect().height; + if (Array.isArray(this.chunkHeights)) { + this.chunkHeights[index] = newHeight; } - from Kotlin`); - this.chunksData=htmlContent; - - let div=document.querySelector(`.chunk-container`); - - if (div && div.innerHTML==='') { - let oldHeight=div.getBoundingClientRect().height; - div.innerHTML=htmlContent; - div.style.height=''; - let newHeight=div.getBoundingClientRect().height; - this.chunkHeights=newHeight; - - if (div.getBoundingClientRect().top < 0) { + if (div.getBoundingClientRect().bottom < 0) { window.scrollBy(0, newHeight - oldHeight); } } @@ -2109,33 +1927,30 @@ window.findFirstVisibleCfi=function (cfiArray) { if (window.checkImagesForDiagnosis) { setTimeout(window.checkImagesForDiagnosis, 100); } - } - } + }, + }; - ; - - const HL_LOG_TAG="HIGHLIGHT_DEBUG"; - - window.HighlightBridgeHelper= { + const HL_LOG_TAG = "HIGHLIGHT_DEBUG"; + window.HighlightBridgeHelper = { updateHighlightStyle: function (cfi, newColorClass, colorId) { console.log(`${HL_LOG_TAG}: updateHighlightStyle called. CFI: ${cfi}, Class: ${newColorClass}`); var allSpans = document.querySelectorAll('span[class*="user-highlight-"]'); - allSpans.forEach(span => { - var currentCfiAttr = span.getAttribute('data-cfi') || ""; - var cfis = currentCfiAttr.split('|'); + allSpans.forEach((span) => { + var currentCfiAttr = span.getAttribute("data-cfi") || ""; + var cfis = currentCfiAttr.split("|"); if (cfis.includes(cfi)) { var classesToRemove = []; for (var i = 0; i < span.classList.length; i++) { - if (span.classList[i].startsWith('user-highlight-')) { + if (span.classList[i].startsWith("user-highlight-")) { classesToRemove.push(span.classList[i]); } } - classesToRemove.forEach(cls => span.classList.remove(cls)); + classesToRemove.forEach((cls) => span.classList.remove(cls)); span.classList.add(newColorClass); } @@ -2158,20 +1973,20 @@ window.findFirstVisibleCfi=function (cfiArray) { } `); - var selection=window.getSelection(); + var selection = window.getSelection(); - if ( !selection || selection.rangeCount===0 || selection.toString().trim()==="") { + if (!selection || selection.rangeCount === 0 || selection.toString().trim() === "") { return; } try { - var range=selection.getRangeAt(0); - var text=range.toString(); + var range = selection.getRangeAt(0); + var text = range.toString(); - var safeStartNode=range.startContainer; - var safeStartOffset=range.startOffset; - var safeEndNode=range.endContainer; - var safeEndOffset=range.endOffset; + var safeStartNode = range.startContainer; + var safeStartOffset = range.startOffset; + var safeEndNode = range.endContainer; + var safeEndOffset = range.endOffset; console.log(`$ { HL_LOG_TAG @@ -2193,16 +2008,16 @@ window.findFirstVisibleCfi=function (cfiArray) { `); - var startResult=getCfiPathForElement(safeStartNode, safeStartOffset); - var startCfi=startResult.cfi; + var startResult = getCfiPathForElement(safeStartNode, safeStartOffset); + var startCfi = startResult.cfi; - var endResult=getCfiPathForElement(safeEndNode, safeEndOffset); - var endCfi=endResult.cfi; + var endResult = getCfiPathForElement(safeEndNode, safeEndOffset); + var endCfi = endResult.cfi; - var finalCfi=startCfi; + var finalCfi = startCfi; - if (startCfi !==endCfi) { - finalCfi=startCfi + "|" + endCfi; + if (startCfi !== endCfi) { + finalCfi = startCfi + "|" + endCfi; console.log(`$ { HL_LOG_TAG @@ -2229,7 +2044,7 @@ window.findFirstVisibleCfi=function (cfiArray) { `); - range=this.normalizeRangeBoundaries(range); + range = this.normalizeRangeBoundaries(range); this.highlightRangeSafe(range, colorClass, finalCfi); selection.removeAllRanges(); @@ -2237,121 +2052,112 @@ window.findFirstVisibleCfi=function (cfiArray) { if (window.HighlightBridge) { window.HighlightBridge.onHighlightCreated(finalCfi, text, colorId); } - } - - catch (e) { - console.log(`$ { + } catch (e) { + console.log( + `$ { HL_LOG_TAG } - : Create Error: ` + e.message); + : Create Error: ` + e.message, + ); } - } - - , + }, normalizeRangeBoundaries: function (range) { - var startContainer=range.startContainer; - var startOffset=range.startOffset; - var endContainer=range.endContainer; - var endOffset=range.endOffset; + var startContainer = range.startContainer; + var startOffset = range.startOffset; + var endContainer = range.endContainer; + var endOffset = range.endOffset; - if (startContainer.nodeType===Node.TEXT_NODE && startOffset > 0 && startOffset < startContainer.length) { - var newStartNode=startContainer.splitText(startOffset); + if (startContainer.nodeType === Node.TEXT_NODE && startOffset > 0 && startOffset < startContainer.length) { + var newStartNode = startContainer.splitText(startOffset); range.setStart(newStartNode, 0); - if (endContainer===startContainer) { - endContainer=newStartNode; - endOffset=endOffset - startOffset; + if (endContainer === startContainer) { + endContainer = newStartNode; + endOffset = endOffset - startOffset; } } - if (endContainer.nodeType===Node.TEXT_NODE && endOffset > 0 && endOffset < endContainer.length) { + if (endContainer.nodeType === Node.TEXT_NODE && endOffset > 0 && endOffset < endContainer.length) { endContainer.splitText(endOffset); range.setEnd(endContainer, endOffset); } return range; - } - - , + }, highlightRangeSafe: function (range, className, newCfi) { - var nodes=this.getTextNodesInRange(range); + var nodes = this.getTextNodesInRange(range); - nodes.forEach(node=> { - var parent=node.parentNode; + nodes.forEach((node) => { + var parent = node.parentNode; - if (parent && parent.tagName==='SPAN' && parent.classList.contains(className)) { - var currentCfi=parent.getAttribute('data-cfi') || ""; - var cfiList=currentCfi.split('|'); + if (parent && parent.tagName === "SPAN" && parent.classList.contains(className)) { + var currentCfi = parent.getAttribute("data-cfi") || ""; + var cfiList = currentCfi.split("|"); - if ( !cfiList.includes(newCfi)) { - parent.setAttribute('data-cfi', currentCfi + "|" + newCfi); - } + if (!cfiList.includes(newCfi)) { + parent.setAttribute("data-cfi", currentCfi + "|" + newCfi); } - - else { - if (node.nodeValue.trim().length===0) return; - var span=document.createElement('span'); - span.className=className; - span.setAttribute('data-cfi', newCfi); - node.parentNode.insertBefore(span, node); - span.appendChild(node); - } - }); - } - - , + } else { + if (node.nodeValue.trim().length === 0) return; + var span = document.createElement("span"); + span.className = className; + span.setAttribute("data-cfi", newCfi); + node.parentNode.insertBefore(span, node); + span.appendChild(node); + } + }); + }, getTextNodesInRange: function (range) { - var textNodes=[]; - var container=range.commonAncestorContainer; - var root=(container.nodeType===Node.TEXT_NODE) ? container.parentNode : container; + var textNodes = []; + var container = range.commonAncestorContainer; + var root = container.nodeType === Node.TEXT_NODE ? container.parentNode : container; - var walker=document.createTreeWalker(root, + var walker = document.createTreeWalker( + root, NodeFilter.SHOW_TEXT, { - acceptNode: function (node) { - return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; - } + acceptNode: function (node) { + return range.intersectsNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }, + }, + + false, + ); + + while (walker.nextNode()) { + textNodes.push(walker.currentNode); } - , - false); + return textNodes; + }, - while (walker.nextNode()) { - textNodes.push(walker.currentNode); - } - - return textNodes; - } - - , - - removeHighlightByCfi: function (cfiToRemove, optionalCssClass) { - console.log(`$ { + removeHighlightByCfi: function (cfiToRemove, optionalCssClass) { + console.log(`$ { HL_LOG_TAG } : removeHighlightByCfi called.`); - console.log(`$ { + console.log(`$ { HL_LOG_TAG } : -> Target CFI: '${cfiToRemove}' `); - console.log(`$ { + console.log(`$ { HL_LOG_TAG } : -> Optional Class: '${optionalCssClass}' `); - // 1. Select all highlight spans manually to avoid selector syntax errors - var allSpans=document.querySelectorAll('span[data-cfi]'); + // 1. Select all highlight spans manually to avoid selector syntax errors + var allSpans = document.querySelectorAll("span[data-cfi]"); - console.log(`$ { + console.log(`$ { HL_LOG_TAG } @@ -2361,13 +2167,13 @@ window.findFirstVisibleCfi=function (cfiArray) { `); - var foundCount=0; - var removedCount=0; - var updatedCount=0; + var foundCount = 0; + var removedCount = 0; + var updatedCount = 0; - allSpans.forEach(span=> { - var currentCfiAttr=span.getAttribute('data-cfi') || ""; - var cfiList=currentCfiAttr.split('|'); + allSpans.forEach((span) => { + var currentCfiAttr = span.getAttribute("data-cfi") || ""; + var cfiList = currentCfiAttr.split("|"); // Detailed check for match if (cfiList.includes(cfiToRemove)) { @@ -2383,25 +2189,21 @@ window.findFirstVisibleCfi=function (cfiArray) { ]`); - var newCfiList=cfiList.filter(c=> c !==cfiToRemove); - - if (newCfiList.length===0) { + var newCfiList = cfiList.filter((c) => c !== cfiToRemove); + if (newCfiList.length === 0) { // CASE 1: No other highlights on this span -> Remove entirely console.log(`$ { HL_LOG_TAG } : -> Removing span entirely (no remaining CFIs).`); - var parent=span.parentNode; + var parent = span.parentNode; while (span.firstChild) parent.insertBefore(span.firstChild, span); parent.removeChild(span); parent.normalize(); removedCount++; - } - - else { - + } else { // CASE 2: Overlapping highlight -> Update data-cfi console.log(`$ { HL_LOG_TAG @@ -2410,7 +2212,7 @@ window.findFirstVisibleCfi=function (cfiArray) { : -> Updating span (remaining CFIs: $ { newCfiList.join('|') }).`); - span.setAttribute('data-cfi', newCfiList.join('|')); + span.setAttribute("data-cfi", newCfiList.join("|")); if (optionalCssClass) { console.log(`$ { @@ -2423,9 +2225,7 @@ window.findFirstVisibleCfi=function (cfiArray) { `); span.classList.remove(optionalCssClass); - } - - else { + } else { console.log(`$ { HL_LOG_TAG } @@ -2438,7 +2238,7 @@ window.findFirstVisibleCfi=function (cfiArray) { } }); - console.log(`$ { + console.log(`$ { HL_LOG_TAG } @@ -2456,117 +2256,104 @@ window.findFirstVisibleCfi=function (cfiArray) { `); - if (foundCount===0) { - console.log(`$ { + if (foundCount === 0) { + console.log(`$ { HL_LOG_TAG } : No exact matches found. Attempting legacy fallback.`); - this.removeHighlightByCfiLegacy(cfiToRemove); - } - } - - , - - removeHighlightByCfiLegacy: function (cfi) { - try { - var location=window.getNodeAndOffsetFromCfi(cfi); - - if (location && location.node) { - var target=location.node; - if (target.nodeType===Node.TEXT_NODE) target=target.parentNode; - - if (target.tagName==='SPAN' && target.className.startsWith ('user-highlight-')) { - var parent=target.parentNode; - while (target.firstChild) parent.insertBefore(target.firstChild, target); - parent.removeChild(target); - parent.normalize(); - } + this.removeHighlightByCfiLegacy(cfiToRemove); } - } + }, - catch (e) {} - } + removeHighlightByCfiLegacy: function (cfi) { + try { + var location = window.getNodeAndOffsetFromCfi(cfi); - , + if (location && location.node) { + var target = location.node; + if (target.nodeType === Node.TEXT_NODE) target = target.parentNode; - restoreHighlights: function (jsonArrayString) { - try { - var highlights=JSON.parse(jsonArrayString); - var self=this; + if (target.tagName === "SPAN" && target.className.startsWith("user-highlight-")) { + var parent = target.parentNode; + while (target.firstChild) parent.insertBefore(target.firstChild, target); + parent.removeChild(target); + parent.normalize(); + } + } + } catch (e) {} + }, - highlights.forEach(function (h) { + restoreHighlights: function (jsonArrayString) { + try { + var highlights = JSON.parse(jsonArrayString); + var self = this; + + highlights.forEach(function (h) { self.applyHighlight(h.cfi, h.text, h.cssClass); }); - } - - catch (e) { - console.log(`$ { + } catch (e) { + console.log( + `$ { HL_LOG_TAG } - : Error restoring: ` + e.message); - } - } + : Error restoring: ` + e.message, + ); + } + }, - , + applyHighlight: function (cfi, text, cssClass) { + // "Healed" Apply Logic: Checks text equality before applying + try { + if (document.querySelector(`span[data-cfi='${cfi}']`)) return; - applyHighlight: function (cfi, text, cssClass) { + const location = window.getNodeAndOffsetFromCfi(cfi); + if (!location || !location.node) return; - // "Healed" Apply Logic: Checks text equality before applying - try { - if (document.querySelector(`span[data-cfi='${cfi}']`)) return; + let startNode = location.node; + let startOffset = location.offset; - const location=window.getNodeAndOffsetFromCfi(cfi); - if ( !location || !location.node) return; + if (startNode.nodeType === Node.TEXT_NODE) { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + walker.currentNode = startNode; - let startNode=location.node; - let startOffset=location.offset; + while (startNode && startOffset >= startNode.nodeValue.length) { + if (startOffset === startNode.nodeValue.length) { + const next = walker.nextNode(); - if (startNode.nodeType===Node.TEXT_NODE) { - const walker=document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - walker.currentNode=startNode; - - while (startNode && startOffset >=startNode.nodeValue.length) { - if (startOffset===startNode.nodeValue.length) { - const next=walker.nextNode(); - - if (next) { - startOffset -=startNode.nodeValue.length; - startNode=next; + if (next) { + startOffset -= startNode.nodeValue.length; + startNode = next; + } else { + break; + } + } else { + startOffset -= startNode.nodeValue.length; + startNode = walker.nextNode(); } - - else { - break; - } - } - - else { - startOffset -=startNode.nodeValue.length; - startNode=walker.nextNode(); } } - } - // 1. Text Verification / Healing - if (text && text.length > 0 && startNode && startNode.nodeType===Node.TEXT_NODE) { - const nodeVal=startNode.nodeValue; - // Check if text matches at exact offset - const substring=nodeVal.substring(startOffset, startOffset + text.length); + // 1. Text Verification / Healing + if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) { + const nodeVal = startNode.nodeValue; + // Check if text matches at exact offset + const substring = nodeVal.substring(startOffset, startOffset + text.length); - // Allow for some whitespace looseness (trim comparison) - if (substring !==text && substring.trim() !==text.trim()) { - console.log(`$ { + // Allow for some whitespace looseness (trim comparison) + if (substring !== text && substring.trim() !== text.trim()) { + console.log(`$ { HL_LOG_TAG } : Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `); - // Try finding the text in the whole node - const foundIndex=nodeVal.indexOf(text); + // Try finding the text in the whole node + const foundIndex = nodeVal.indexOf(text); - if (foundIndex !==-1) { - console.log(`$ { + if (foundIndex !== -1) { + console.log(`$ { HL_LOG_TAG } @@ -2579,92 +2366,83 @@ window.findFirstVisibleCfi=function (cfiArray) { } .`); - startOffset=foundIndex; - } + startOffset = foundIndex; + } else { + // Simple fuzzy: Try finding first 20 chars + const partial = text.substring(0, Math.min(text.length, 20)); + const partialIndex = nodeVal.indexOf(partial); - else { - // Simple fuzzy: Try finding first 20 chars - const partial=text.substring(0, Math.min(text.length, 20)); - const partialIndex=nodeVal.indexOf(partial); - - if (partialIndex !==-1) { - console.log(`$ { + if (partialIndex !== -1) { + console.log(`$ { HL_LOG_TAG } : Found partial match. Adjusting offset.`); - startOffset=partialIndex; + startOffset = partialIndex; + } } } } - } - if ( !startNode) return; + if (!startNode) return; - const range=document.createRange(); + const range = document.createRange(); - // Set Start - if (startNode.nodeType===Node.TEXT_NODE) { - - // Ensure offset is valid - if (startOffset > startNode.nodeValue.length) { - startOffset=Math.max(0, startNode.nodeValue.length - 1); - } - } - - range.setStart(startNode, startOffset); - - const treeWalker=document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); - treeWalker.currentNode=startNode; - - let currentNode=treeWalker.currentNode; - let remainingOffset=startOffset; - let remainingLen=text.length; - let endNode=currentNode; - let endOffset=startOffset; - - while (remainingLen > 0 && endNode) { - let avail=endNode.nodeValue.length - endOffset; - - if (avail >=remainingLen) { - endOffset +=remainingLen; - remainingLen=0; + // Set Start + if (startNode.nodeType === Node.TEXT_NODE) { + // Ensure offset is valid + if (startOffset > startNode.nodeValue.length) { + startOffset = Math.max(0, startNode.nodeValue.length - 1); + } } - else { - remainingLen -=avail; - endNode=treeWalker.nextNode(); - endOffset=0; + range.setStart(startNode, startOffset); + + const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); + treeWalker.currentNode = startNode; + + let currentNode = treeWalker.currentNode; + let remainingOffset = startOffset; + let remainingLen = text.length; + let endNode = currentNode; + let endOffset = startOffset; + + while (remainingLen > 0 && endNode) { + let avail = endNode.nodeValue.length - endOffset; + + if (avail >= remainingLen) { + endOffset += remainingLen; + remainingLen = 0; + } else { + remainingLen -= avail; + endNode = treeWalker.nextNode(); + endOffset = 0; + } } + + if (endNode) { + range.setEnd(endNode, endOffset); + var normalizedRange = this.normalizeRangeBoundaries(range); + this.highlightRangeSafe(normalizedRange, cssClass, cfi); + } + } catch (e) { + console.log(e); } - - if (endNode) { - range.setEnd(endNode, endOffset); - var normalizedRange=this.normalizeRangeBoundaries(range); - this.highlightRangeSafe(normalizedRange, cssClass, cfi); - } - } - - catch (e) { - console.log(e); - } - } -} - -; + }, + }; })(); (function () { - const TAG_AUTO_SCROLL="AutoScrollDiagnosis"; + const TAG_AUTO_SCROLL = "AutoScrollDiagnosis"; - window.autoScroll= { - active: false, - speed: 1.0, - accumulator: 0.0, - animationId: null, + window.autoScroll = { + active: false, + speed: 1.0, + accumulator: 0.0, + animationId: null, - start: function (speed) { - console.log(`$ { + start: function (speed) { + console.log(`$ { TAG_AUTO_SCROLL } @@ -2673,29 +2451,29 @@ window.findFirstVisibleCfi=function (cfiArray) { } `); - this.active=true; - this.speed=speed || this.speed; - this.accumulator=0.0; - if (this.animationId) cancelAnimationFrame(this.animationId); - this.loop(); - }, + this.active = true; + this.speed = speed || this.speed; + this.accumulator = 0.0; + if (this.animationId) cancelAnimationFrame(this.animationId); + this.loop(); + }, - stop: function () { - this.active = false; - if (this.animationId) { - cancelAnimationFrame(this.animationId); - this.animationId = null; - } + stop: function () { + this.active = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } - const container = document.getElementById('content-container') || document.body; - if (container) { - container.style.transform = 'none'; - window.scrollBy(0, 0); - } - }, + const container = document.getElementById("content-container") || document.body; + if (container) { + container.style.transform = "none"; + window.scrollBy(0, 0); + } + }, - updateSpeed: function (newSpeed) { - console.log(`$ { + updateSpeed: function (newSpeed) { + console.log(`$ { TAG_AUTO_SCROLL } @@ -2704,45 +2482,43 @@ window.findFirstVisibleCfi=function (cfiArray) { } `); - this.speed=newSpeed; + this.speed = newSpeed; + }, + + loop: function () { + if (!this.active) return; + + this.accumulator += this.speed; + + const totalPixelsToScroll = Math.floor(this.accumulator); + + if (totalPixelsToScroll >= 1) { + const prevScrollY = window.scrollY; + window.scrollBy(0, totalPixelsToScroll); + + this.accumulator -= totalPixelsToScroll; + + const scrollY = window.scrollY; + const docHeight = document.documentElement.scrollHeight; + const innerH = window.innerHeight; + const isAtBottom = scrollY + innerH >= docHeight - 3; + const isStuck = totalPixelsToScroll > 0 && scrollY === prevScrollY && prevScrollY > 0; + + if (isAtBottom || isStuck) { + this.stop(); + if (window.AutoScrollBridge && window.AutoScrollBridge.onChapterEnd) { + window.AutoScrollBridge.onChapterEnd(); + } + return; + } } - , + const container = document.getElementById("content-container") || document.body; + if (container) { + container.style.transform = `translate3d(0, -${this.accumulator}px, 0)`; + } - loop: function () { - if (!this.active) return; - - this.accumulator += this.speed; - - const totalPixelsToScroll = Math.floor(this.accumulator); - - if (totalPixelsToScroll >= 1) { - const prevScrollY = window.scrollY; - window.scrollBy(0, totalPixelsToScroll); - - this.accumulator -= totalPixelsToScroll; - - const scrollY = window.scrollY; - const docHeight = document.documentElement.scrollHeight; - const innerH = window.innerHeight; - const isAtBottom = (scrollY + innerH) >= (docHeight - 3); - const isStuck = (totalPixelsToScroll > 0 && scrollY === prevScrollY && prevScrollY > 0); - - if (isAtBottom || isStuck) { - this.stop(); - if (window.AutoScrollBridge && window.AutoScrollBridge.onChapterEnd) { - window.AutoScrollBridge.onChapterEnd(); - } - return; - } - } - - const container = document.getElementById('content-container') || document.body; - if (container) { - container.style.transform = `translate3d(0, -${this.accumulator}px, 0)`; - } - - this.animationId = requestAnimationFrame(this.loop.bind(this)); - }, - }; - })(); \ No newline at end of file + this.animationId = requestAnimationFrame(this.loop.bind(this)); + }, + }; +})(); diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt index c28370c..faa30d1 100644 --- a/app/src/main/java/com/aryan/reader/AppNavigation.kt +++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt @@ -128,8 +128,6 @@ fun AppNavigation( initialPage = initialPage, initialBookmarksJson = initialBookmarksJson, isProUser = uiState.isProUser, - pendingSyncUpdate = uiState.pendingSyncUpdate?.takeIf { it.bookId == bookId }, - onClearPendingSyncUpdate = viewModel::clearPendingSyncUpdate, onNavigateBack = { Timber.d("Back action triggered from PDF Viewer.") viewModel.clearSelectedFile() @@ -182,8 +180,6 @@ fun AppNavigation( initialBookmarksJson = initialBookmarksJson, isProUser = uiState.isProUser, coverImagePath = coverPath, - pendingSyncUpdate = uiState.pendingSyncUpdate?.takeIf { it.bookId == bookId }, - onClearPendingSyncUpdate = viewModel::clearPendingSyncUpdate, onNavigateBack = { Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.") viewModel.clearSelectedFile() diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt index 697ea94..f3eb645 100644 --- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt +++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt @@ -17,6 +17,7 @@ * * mail: epistemereader@gmail.com */ +// FolderSyncWorker.kt package com.aryan.reader import android.content.Context @@ -24,8 +25,10 @@ import android.net.Uri import timber.log.Timber import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile -import androidx.work.CoroutineWorker +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager +import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFilesRepository @@ -33,8 +36,11 @@ import com.aryan.reader.epub.EpubParser import com.aryan.reader.epub.MobiParser import com.aryan.reader.pdf.PdfCoverGenerator import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import androidx.core.content.edit +import com.aryan.reader.data.LocalSyncUtils class FolderSyncWorker( private val appContext: Context, @@ -42,182 +48,362 @@ class FolderSyncWorker( ) : CoroutineWorker(appContext, workerParams) { private val recentFilesRepository = RecentFilesRepository(appContext) - private val bookImporter = BookImporter(appContext) private val epubParser = EpubParser(appContext) private val mobiParser = MobiParser(appContext) private val pdfCoverGenerator = PdfCoverGenerator(appContext) + private val bookImporter = BookImporter(appContext) companion object { const val WORK_NAME = "FolderSyncWorker" + const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime" + const val KEY_METADATA_ONLY = "key_metadata_only" + private val syncMutex = Mutex() } override suspend fun doWork(): Result { - Timber.d("Worker starting folder sync check.") + val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false) + Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...") + + return withContext(Dispatchers.IO) { + syncMutex.withLock { + Timber.tag("FolderSync").d("Worker: Lock acquired. Starting Sync.") + performSync(isMetadataOnly) + } + } + } + + private suspend fun performSync(metadataOnly: Boolean): Result { val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE) val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null) - if (folderUriString.isNullOrBlank()) { - Timber.d("No sync folder configured. Worker stopping.") - return Result.success() - } - + if (folderUriString.isNullOrBlank()) return Result.success() val folderUri = folderUriString.toUri() - return withContext(Dispatchers.IO) { + try { try { - val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) - if (documentTree == null || !documentTree.isDirectory) { - Timber.e("Could not read the synced folder URI: $folderUriString. Cancelling worker.") - WorkManager.getInstance(appContext).cancelUniqueWork(WORK_NAME) - return@withContext Result.failure() - } + appContext.contentResolver.takePersistableUriPermission( + folderUri, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: SecurityException) { + return Result.failure() + } - val filesToScan = mutableListOf() + val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) + if (documentTree == null || !documentTree.isDirectory) { + return Result.failure() + } + + val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) + + if (!metadataOnly) { + val currentDiskFiles = mutableListOf() val fileQueue = ArrayDeque() documentTree.listFiles().let { fileQueue.addAll(it) } while (fileQueue.isNotEmpty()) { val file = fileQueue.removeAt(0) if (file.isDirectory) { + if (file.name == ".episteme") continue file.listFiles().let { fileQueue.addAll(it) } } else if (file.isFile) { - val fileName = file.name ?: "" - if (fileName.endsWith(".pdf", true) || fileName.endsWith(".epub", true) || fileName.endsWith(".mobi", true) || fileName.endsWith(".azw3", true)) { - filesToScan.add(file) + val name = file.name ?: "" + if (isValidExtension(name)) { + currentDiskFiles.add(file) } } } - var importedCount = 0 - for (file in filesToScan) { - val importResult = prepareBookForImport(file.uri) - if (importResult != null) { - val (internalUri, bookId, type) = importResult - val displayName = file.name ?: "Unknown File" - addBookToDatabase(internalUri, type, bookId, displayName, folderUriString) - importedCount++ + val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) + + val legacyLookup = activeDbBooks.associateBy { it.displayName } + + val foundBookIds = mutableSetOf() + + for (file in currentDiskFiles) { + val stableId = "local_${file.name}_${file.length()}" + + var existingItem = recentFilesRepository.getFileByBookId(stableId) + var bookIdToUse = stableId + var isMigration = false + + if (existingItem == null) { + val legacyMatch = legacyLookup[file.name] + if (legacyMatch != null) { + Timber.tag("FolderSync").i("Migration: Found legacy match for ${file.name}. ID: ${legacyMatch.bookId}") + + existingItem = legacyMatch + bookIdToUse = legacyMatch.bookId + isMigration = true + } + } + + foundBookIds.add(bookIdToUse) + + if (existingItem == null) { + val remoteMeta = folderMetadataMap[stableId] + val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB + + // --- CHANGED: Removed extractFileInfo() call --- + // We use placeholders. The MetadataExtractionWorker will fix this later. + val placeholderTitle = file.name ?: "Unknown" + val placeholderAuthor = null + val placeholderCover = null + + if (remoteMeta != null) { + Timber.tag("FolderSync").d("Worker: Importing existing book from Metadata + File: ${file.name}") + // We prefer remoteMeta if available because it might have the correct title/author from a previous sync + val tempItem = RecentFileItem( + bookId = stableId, + uriString = file.uri.toString(), + type = type, + displayName = file.name ?: "Unknown", + timestamp = remoteMeta.lastModifiedTimestamp, + lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, + coverImagePath = null, // Will be fetched by MetadataWorker if needed + title = remoteMeta.title ?: placeholderTitle, + author = remoteMeta.author, + isAvailable = true, + isDeleted = false, + isRecent = false, + sourceFolderUri = folderUriString, + lastChapterIndex = remoteMeta.lastChapterIndex, + lastPage = remoteMeta.lastPage, + lastPositionCfi = remoteMeta.lastPositionCfi, + progressPercentage = remoteMeta.progressPercentage, + bookmarksJson = remoteMeta.bookmarksJson + ) + recentFilesRepository.addRecentFile(tempItem) + } else { + // FAST PATH: Insert barebones item + val newItem = RecentFileItem( + bookId = stableId, + uriString = file.uri.toString(), + type = type, + displayName = file.name ?: "Unknown", + timestamp = System.currentTimeMillis(), + coverImagePath = null, // Background worker will fill this + title = placeholderTitle, + author = null, + isAvailable = true, + lastModifiedTimestamp = System.currentTimeMillis(), + isDeleted = false, + isRecent = false, + sourceFolderUri = folderUriString + ) + recentFilesRepository.addRecentFile(newItem) + } + } else { + if (isMigration) { + val oldUriString = existingItem.uriString + val newUriString = file.uri.toString() + + if (oldUriString != newUriString) { + Timber.tag("FolderSync").i("Migration: Updating URI and cleaning up internal storage for $bookIdToUse") + + if (oldUriString != null) { + bookImporter.deleteBookByUriString(oldUriString) + } + + existingItem = existingItem.copy( + uriString = newUriString, + isAvailable = true + ) + recentFilesRepository.addRecentFile(existingItem) + } + } else if (existingItem.isDeleted) { + val resurrected = existingItem.copy(isDeleted = false, isAvailable = true) + recentFilesRepository.addRecentFile(resurrected) + } + + val remoteMeta = folderMetadataMap[bookIdToUse] + + if (remoteMeta == null) { + recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse) + } else { + if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { + Timber.tag("FolderSync").d("Worker: Remote metadata newer for ${file.name}") + val updatedItem = existingItem.copy( + lastChapterIndex = remoteMeta.lastChapterIndex, + lastPage = remoteMeta.lastPage, + lastPositionCfi = remoteMeta.lastPositionCfi, + progressPercentage = remoteMeta.progressPercentage, + bookmarksJson = remoteMeta.bookmarksJson, + locatorBlockIndex = remoteMeta.locatorBlockIndex, + locatorCharOffset = remoteMeta.locatorCharOffset, + lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, + timestamp = remoteMeta.lastModifiedTimestamp + ) + recentFilesRepository.addRecentFile(updatedItem) + } else if (existingItem.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) { + recentFilesRepository.syncLocalMetadataToFolder(bookIdToUse) + } + } } } - if (importedCount > 0) { - Timber.d("Worker successfully imported $importedCount new book(s).") - } else { - Timber.d("Worker found no new books to import.") + val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) + val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId } + + if (idsToRemove.isNotEmpty()) { + Timber.tag("FolderSync").i("Cleaning up ${idsToRemove.size} missing folder books.") + recentFilesRepository.deleteFilePermanently(idsToRemove) } - prefs.edit { - putLong( - MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, - System.currentTimeMillis() - ) - } + val orphanedMetadataIds = folderMetadataMap.keys.filter { !foundBookIds.contains(it) } - Result.success() - } catch (e: Exception) { - Timber.e(e, "Error during folder sync worker execution.") - Result.failure() + if (orphanedMetadataIds.isNotEmpty()) { + Timber.tag("FolderSync").i("Cleaning up ${orphanedMetadataIds.size} orphaned metadata files.") + + try { + val docTree = DocumentFile.fromTreeUri(appContext, folderUri) + val syncDir = docTree?.findFile("episteme") + + if (syncDir != null) { + val allFiles = syncDir.listFiles() + orphanedMetadataIds.forEach { orphanId -> + allFiles.filter { + val name = it.name ?: "" + name.contains(orphanId) && (name.endsWith(".json") || name.contains(".sync-conflict")) + }.forEach { fileToDelete -> + try { + fileToDelete.delete() + } catch (_: Exception) { } + } + } + } + } catch (e: Exception) { + Timber.tag("FolderSync").e(e, "Error during orphan cleanup") + } + } } + + // Reconcile Metadata (Write-back) + val activeDbBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString) + + val booksToDelete = mutableListOf() + + for (localBook in activeDbBooks) { + val remoteMeta = folderMetadataMap[localBook.bookId] + if (remoteMeta == null) { + val exists = try { + val uri = localBook.uriString?.toUri() + if (uri != null) { + DocumentFile.fromSingleUri(appContext, uri)?.exists() == true + } else false + } catch (e: Exception) { false } + + if (exists) { + recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId) + } else { + Timber.tag("FolderSync").i("Metadata Sync: Book ${localBook.displayName} missing from disk. Scheduling removal.") + booksToDelete.add(localBook.bookId) + } + } else { + if (remoteMeta.lastModifiedTimestamp > localBook.lastModifiedTimestamp) { + Timber.tag("FolderSync").d("SyncDecision: Remote NEWER for ${localBook.displayName}. Updating local DB.") + val updatedItem = localBook.copy( + lastChapterIndex = remoteMeta.lastChapterIndex, + lastPage = remoteMeta.lastPage, + lastPositionCfi = remoteMeta.lastPositionCfi, + progressPercentage = remoteMeta.progressPercentage, + bookmarksJson = remoteMeta.bookmarksJson, + locatorBlockIndex = remoteMeta.locatorBlockIndex, + locatorCharOffset = remoteMeta.locatorCharOffset, + lastModifiedTimestamp = remoteMeta.lastModifiedTimestamp, + timestamp = remoteMeta.lastModifiedTimestamp + ) + recentFilesRepository.addRecentFile(updatedItem) + } else if (localBook.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) { + recentFilesRepository.syncLocalMetadataToFolder(localBook.bookId) + } + } + } + + if (booksToDelete.isNotEmpty()) { + recentFilesRepository.deleteFilePermanently(booksToDelete) + } + + prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) } + + Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.") + val metaRequest = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(appContext).enqueueUniqueWork( + MetadataExtractionWorker.WORK_NAME, + ExistingWorkPolicy.APPEND_OR_REPLACE, + metaRequest + ) + + return Result.success() + } catch (e: Exception) { + Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.") + return Result.failure() } } - private suspend fun prepareBookForImport(externalUri: Uri): Triple? { - val type = getFileTypeFromUri(externalUri, appContext) ?: return null + private data class ExtractedInfo( + val title: String? = null, + val author: String? = null, + val coverPath: String? = null + ) - val hash = FileHasher.calculateSha256 { - appContext.contentResolver.openInputStream(externalUri) - } ?: return null - - if (recentFilesRepository.getFileByBookId(hash) != null) { - return null // Already exists - } - - val internalFile = bookImporter.importBook(externalUri) ?: return null - return Triple(internalFile.toUri(), hash, type) - } - - private fun getFileNameFromUri(uri: Uri): String? { - return DocumentFile.fromSingleUri(appContext, uri)?.name - } - - private suspend fun addBookToDatabase( - uri: Uri, - type: FileType, - bookId: String, - displayName: String, - sourceFolderUri: String - ) { + private suspend fun extractFileInfo(uri: Uri, type: FileType, displayName: String): ExtractedInfo { var coverPath: String? = null var title: String? = null var author: String? = null - if (type == FileType.EPUB || type == FileType.MOBI) { - val book = withContext(Dispatchers.IO) { - appContext.contentResolver.openInputStream(uri)?.use { inputStream -> - if (type == FileType.EPUB) { - epubParser.createEpubBook( - inputStream = inputStream, - originalBookNameHint = displayName - ) - } else { - mobiParser.createMobiBook( - inputStream = inputStream, - originalBookNameHint = displayName - ) + try { + if (type == FileType.EPUB || type == FileType.MOBI) { + val book = withContext(Dispatchers.IO) { + appContext.contentResolver.openInputStream(uri)?.use { inputStream -> + if (type == FileType.EPUB) { + epubParser.createEpubBook( + inputStream = inputStream, + originalBookNameHint = displayName, + parseContent = false + ) + } else { + mobiParser.createMobiBook( + inputStream = inputStream, + originalBookNameHint = displayName + ) + } } } - } - if (book != null) { - title = book.title.takeIf { it.isNotBlank() } ?: displayName - author = book.author.takeIf { it.isNotBlank() } - book.coverImage?.let { + if (book != null) { + title = book.title.takeIf { it.isNotBlank() } + author = book.author.takeIf { it.isNotBlank() } + book.coverImage?.let { + coverPath = recentFilesRepository.saveCoverToCache(it, uri) + } + } + } else if (type == FileType.PDF) { + pdfCoverGenerator.generateCover(uri)?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) } } - } else if (type == FileType.PDF) { - title = displayName - pdfCoverGenerator.generateCover(uri)?.let { - coverPath = recentFilesRepository.saveCoverToCache(it, uri) - } + } catch (e: Exception) { + Timber.e(e, "Failed to extract info for file: $displayName") } - - val newItem = RecentFileItem( - bookId = bookId, - uriString = uri.toString(), - type = type, - displayName = displayName, - timestamp = System.currentTimeMillis(), - coverImagePath = coverPath, - title = title, - author = author, - isAvailable = true, - lastModifiedTimestamp = System.currentTimeMillis(), - isDeleted = false, - isRecent = false, // Books from folder sync should not appear on the Home screen - sourceFolderUri = sourceFolderUri - ) - recentFilesRepository.addRecentFile(newItem) - Timber.i("Worker added new book to database: $displayName") + return ExtractedInfo(title, author, coverPath) } - private fun getFileTypeFromUri(uri: Uri, context: Context): FileType? { - val mimeType = context.contentResolver.getType(uri) - return when (mimeType) { - "application/pdf" -> FileType.PDF - "application/epub+zip" -> FileType.EPUB - "application/x-mobipocket-ebook", - "application/vnd.amazon.ebook", - "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI - else -> { - val path = getFileNameFromUri(uri) - when { - path?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF - path?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB - path?.endsWith(".mobi", ignoreCase = true) == true -> FileType.MOBI - path?.endsWith(".azw3", ignoreCase = true) == true -> FileType.MOBI - path?.endsWith(".prc", ignoreCase = true) == true -> FileType.MOBI - else -> null - } - } + private fun isValidExtension(name: String): Boolean { + return name.endsWith(".pdf", true) || + name.endsWith(".epub", true) || + name.endsWith(".mobi", true) || + name.endsWith(".azw3", true) || + name.endsWith(".md", true) + } + + private fun getFileType(name: String, mimeType: String?): FileType? { + return when { + mimeType == "application/pdf" || name.endsWith(".pdf", true) -> FileType.PDF + mimeType == "application/epub+zip" || name.endsWith(".epub", true) -> FileType.EPUB + name.endsWith(".mobi", true) || name.endsWith(".azw3", true) -> FileType.MOBI + name.endsWith(".md", true) -> FileType.MD + name.endsWith(".txt", true) -> FileType.TXT + else -> null } } } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt index 6cca295..8f282e4 100644 --- a/app/src/main/java/com/aryan/reader/HomeScreen.kt +++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt @@ -17,6 +17,7 @@ * * mail: epistemereader@gmail.com */ +// HomeScreen @file:Suppress("DEPRECATION") package com.aryan.reader @@ -53,6 +54,8 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert @@ -83,6 +86,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass @@ -253,6 +257,7 @@ fun HomeScreen( } }, navController = navController, + onFolderSyncToggle = viewModel::setFolderSyncEnabled ) }) { Scaffold( @@ -271,7 +276,8 @@ fun HomeScreen( drawerState.open() } }, - onShowDeviceManagement = viewModel::showDeviceManagementForDebug + onShowDeviceManagement = viewModel::showDeviceManagementForDebug, + onFolderSyncToggle = viewModel::setFolderSyncEnabled ) } else { ContextualTopAppBar( @@ -291,14 +297,16 @@ fun HomeScreen( if (uiState.recentFiles.isEmpty()) { EmptyState( title = "Your Library is Empty", - message = "Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started.", + message = "Select a file to read, or sync a local folder to automatically import books.", onSelectFileClick = onSelectFileClick, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + secondaryButtonText = "Setup Folder Sync", + onSecondaryClick = { viewModel.navigateToFolderSync() } ) } else { EmptyState( title = "No Recent Files", - message = "Open a file from your library to see it here, or select a new file to add.", + message = "Open a file from your library to see it here.", onSelectFileClick = onSelectFileClick, modifier = Modifier.weight(1f) ) @@ -310,8 +318,13 @@ fun HomeScreen( onItemClick = { item -> viewModel.onRecentFileClicked(item) }, onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) }, onSelectFileClick = onSelectFileClick, + onNavigateToFolderSync = { viewModel.navigateToFolderSync() }, windowSizeClass = windowSizeClass, - downloadingBookIds = uiState.downloadingBookIds + downloadingBookIds = uiState.downloadingBookIds, + onRefresh = { viewModel.refreshLibrary() }, + isRefreshing = uiState.isRefreshing, + isSyncEnabled = uiState.isSyncEnabled, + hasSyncedFolder = uiState.syncedFolderUri != null ) } } @@ -389,9 +402,15 @@ fun HomeScreen( ) } } + if (uiState.showFolderMigrationDialog) { + FolderMigrationDialog( + onConfirm = { viewModel.completeFolderMigration() } + ) + } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RecentFilesContent( recentFiles: List, @@ -399,30 +418,60 @@ private fun RecentFilesContent( onItemClick: (RecentFileItem) -> Unit, onItemLongClick: (RecentFileItem) -> Unit, onSelectFileClick: () -> Unit, + onNavigateToFolderSync: () -> Unit, windowSizeClass: WindowSizeClass, downloadingBookIds: Set, + onRefresh: () -> Unit, + isRefreshing: Boolean, + isSyncEnabled: Boolean, + hasSyncedFolder: Boolean ) { - Box(modifier = Modifier.fillMaxSize()) { - RecentFilesGrid( - modifier = Modifier.padding(horizontal = 16.dp), - recentFiles = recentFiles, - selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(), - onItemClick = onItemClick, - onItemLongClick = onItemLongClick, - windowSizeClass = windowSizeClass, - contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp), - downloadingBookIds = downloadingBookIds - ) + val canRefresh = isSyncEnabled || hasSyncedFolder - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(vertical = 24.dp), contentAlignment = Alignment.Center - ) { - SelectFileButton(onClick = onSelectFileClick, text = "Select Another File") + val content = @Composable { + Box(modifier = Modifier.fillMaxSize()) { + RecentFilesGrid( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + recentFiles = recentFiles, + selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(), + onItemClick = onItemClick, + onItemLongClick = onItemLongClick, + windowSizeClass = windowSizeClass, + contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp), + downloadingBookIds = downloadingBookIds + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + androidx.compose.material3.Button(onClick = onSelectFileClick) { + Text("Select File") + } + androidx.compose.material3.OutlinedButton(onClick = onNavigateToFolderSync) { + Text("Sync Folder") + } + } } } + + if (canRefresh) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + content() + } + } else { + content() + } } @Composable @@ -509,6 +558,26 @@ fun RecentFileCard( .fillMaxWidth(), ) + if (item.sourceFolderUri != null) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.9f), + shape = CircleShape + ) + .padding(4.dp) + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = "Local Folder", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + if (!item.isAvailable) { Box( modifier = Modifier @@ -576,7 +645,8 @@ fun DefaultTopAppBar( onClearCloudData: () -> Unit, onDrawerClick: () -> Unit, onAboutClick: () -> Unit, - onShowDeviceManagement: () -> Unit + onShowDeviceManagement: () -> Unit, + onFolderSyncToggle: (Boolean) -> Unit ) { var showOptionsMenu by remember { mutableStateOf(false) } @@ -636,7 +706,8 @@ private fun AppDrawerContent( onUpgradeClick: () -> Unit, onSyncUpsellClick: () -> Unit, onFontsClick: () -> Unit, - navController: NavHostController + navController: NavHostController, + onFolderSyncToggle: (Boolean) -> Unit ) { val isOss = BuildConfig.FLAVOR == "oss" @@ -682,10 +753,10 @@ private fun AppDrawerContent( Spacer(modifier = Modifier.height(8.dp)) NavigationDrawerItem( icon = { - Icon( - Icons.Outlined.AccountCircle, contentDescription = "Sign In" - ) - }, + Icon( + Icons.Outlined.AccountCircle, contentDescription = "Sign In" + ) + }, label = { Text("Sign in with Google") }, selected = false, onClick = onSignInClick, @@ -705,10 +776,10 @@ private fun AppDrawerContent( NavigationDrawerItem( icon = { - Icon( - Icons.Default.VerifiedUser, contentDescription = "Episteme Pro" - ) - }, + Icon( + Icons.Default.VerifiedUser, contentDescription = "Episteme Pro" + ) + }, label = { val text = if (uiState.isProUser) "Episteme Pro" else "Upgrade to Episteme Pro" @@ -723,34 +794,63 @@ private fun AppDrawerContent( if (uiState.currentUser != null) { NavigationDrawerItem( icon = { - Icon( - painter = painterResource(id = R.drawable.sync), - contentDescription = "Sync Library" - ) - }, label = { Text("Sync Library") }, badge = { - Row(verticalAlignment = Alignment.CenterVertically) { - if (!uiState.isProUser) { - Icon( - imageVector = Icons.Default.VerifiedUser, - contentDescription = "Pro Feature", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Switch( - checked = uiState.isSyncEnabled, onCheckedChange = { - if (uiState.isProUser) onSyncToggle(it) else onSyncUpsellClick() - }, enabled = uiState.isProUser + Icon( + painter = painterResource(id = R.drawable.sync), + contentDescription = "Sync Library" ) - } - }, selected = false, onClick = { - if (uiState.isProUser) { - onSyncToggle(!uiState.isSyncEnabled) - } else { - onSyncUpsellClick() - } - }, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + }, label = { Text("Sync Library") }, badge = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (!uiState.isProUser) { + Icon( + imageVector = Icons.Default.VerifiedUser, + contentDescription = "Pro Feature", + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Switch( + checked = uiState.isSyncEnabled, onCheckedChange = { + if (uiState.isProUser) onSyncToggle(it) else onSyncUpsellClick() + }, enabled = uiState.isProUser + ) + } + }, selected = false, onClick = { + if (uiState.isProUser) { + onSyncToggle(!uiState.isSyncEnabled) + } else { + onSyncUpsellClick() + } + }, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + } + if (uiState.currentUser != null && uiState.isSyncEnabled) { + NavigationDrawerItem( + icon = { + Icon( + imageVector = Icons.Default.FolderSpecial, + contentDescription = "Backup Local Folders" + ) + }, + label = { + Column { + Text("Cloud sync for Local Folders") + Text( + "Upload books from your synced folders to Google Drive).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + badge = { + Switch( + checked = uiState.isFolderSyncEnabled, + onCheckedChange = { onFolderSyncToggle(it) } + ) + }, + selected = false, + onClick = { onFolderSyncToggle(!uiState.isFolderSyncEnabled) }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) ) } } else { @@ -774,11 +874,11 @@ private fun AppDrawerContent( NavigationDrawerItem( icon = { - Icon( - painter = painterResource(id = R.drawable.fonts), - contentDescription = "Custom Fonts" - ) - }, + Icon( + painter = painterResource(id = R.drawable.fonts), + contentDescription = "Custom Fonts" + ) + }, label = { Text("Custom Fonts") }, selected = false, onClick = onFontsClick, @@ -788,11 +888,11 @@ private fun AppDrawerContent( if (!isOss) { NavigationDrawerItem( icon = { - Icon( - painter = painterResource(id = R.drawable.feedback), - contentDescription = "Feedback" - ) - }, + Icon( + painter = painterResource(id = R.drawable.feedback), + contentDescription = "Feedback" + ) + }, label = { Text("Help & Feedback") }, badge = { if (uiState.hasUnreadFeedback) { @@ -807,11 +907,11 @@ private fun AppDrawerContent( if (uiState.currentUser != null) { NavigationDrawerItem( icon = { - Icon( - painter = painterResource(id = R.drawable.logout), - contentDescription = "Sign Out" - ) - }, + Icon( + painter = painterResource(id = R.drawable.logout), + contentDescription = "Sign Out" + ) + }, label = { Text("Sign Out") }, selected = false, onClick = onSignOutClick, @@ -1035,4 +1135,31 @@ fun FpsMonitor(modifier: Modifier = Modifier) { .background(Color.Black.copy(alpha = 0.5f)) .padding(4.dp) ) +} + +@Composable +private fun FolderMigrationDialog(onConfirm: () -> Unit) { + AlertDialog( + onDismissRequest = { }, // Force acknowledgment + icon = { Icon(Icons.Default.FolderSpecial, contentDescription = null) }, + title = { Text("Folder Sync Refactored") }, + text = { + Column { + Text( + "We've completely rebuilt how Folder Sync works! Books from the folder are now read directly from your folder instead of being copied to app storage." + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + "We'll now perform a one-time scan to migrate your existing progress and bookmarks. This will also free up internal storage space on your device.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Start Migration") + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt index 0210942..05ece93 100644 --- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt +++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt @@ -17,13 +17,17 @@ * * mail: epistemereader@gmail.com */ +// LibraryScreen.kt package com.aryan.reader import android.content.Context import android.provider.DocumentsContract import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -49,14 +53,20 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderSpecial import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -67,6 +77,7 @@ import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -79,6 +90,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -88,6 +100,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import coil.request.ImageRequest @@ -96,20 +110,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.File -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.FolderSpecial -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Button -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign -import androidx.documentfile.provider.DocumentFile import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import androidx.core.net.toUri private fun getBookCountString(count: Int): String { return if (count == 1) "1 book" else "$count books" @@ -130,6 +133,17 @@ fun LibraryScreen( initialPage = uiState.libraryScreenStartPage, pageCount = { 3 } ) + + val containsFolderItems = remember(selectedItems) { + selectedItems.any { it.sourceFolderUri != null } + } + + LaunchedEffect(uiState.libraryScreenStartPage) { + if (pagerState.currentPage != uiState.libraryScreenStartPage) { + pagerState.animateScrollToPage(uiState.libraryScreenStartPage) + } + } + val scope = rememberCoroutineScope() var isSearchActive by remember { mutableStateOf(false) } @@ -160,8 +174,11 @@ fun LibraryScreen( pickFileLauncher.launch(arrayOf("*/*")) } - LaunchedEffect(pagerState.currentPage) { - viewModel.setLibraryScreenPage(pagerState.currentPage) + LaunchedEffect(pagerState) { + androidx.compose.runtime.snapshotFlow { pagerState.settledPage } + .collect { page -> + viewModel.setLibraryScreenPage(page) + } } var showDeleteConfirmDialog by remember { mutableStateOf(false) } @@ -217,6 +234,7 @@ fun LibraryScreen( onNewShelfClick = viewModel::showCreateShelfDialog, onSelectFileClick = onSelectFileClick, onScanNowClick = viewModel::scanSyncedFolder, + onSyncMetadataClick = viewModel::syncFolderMetadata, onSelectSyncFolderClick = onSelectSyncFolderClick, onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder, downloadingBookIds = uiState.downloadingBookIds, @@ -241,9 +259,11 @@ fun LibraryScreen( showDeleteConfirmDialog = false }, onDismiss = { showDeleteConfirmDialog = false }, - isPermanentDelete = true + isPermanentDelete = true, + containsFolderItems = containsFolderItems ) } + if (showDeleteShelvesDialog) { DeleteShelvesConfirmationDialog( count = selectedShelves.size, @@ -409,6 +429,7 @@ fun LibraryScreenContent( onNewShelfClick: () -> Unit, onSelectFileClick: () -> Unit, onScanNowClick: () -> Unit, + onSyncMetadataClick: () -> Unit, onSelectSyncFolderClick: () -> Unit, onDisconnectSyncFolderClick: () -> Unit, downloadingBookIds: Set, @@ -621,6 +642,7 @@ fun LibraryScreenContent( lastScanTime = lastFolderScanTime, onSelectFolderClick = onSelectSyncFolderClick, onScanNowClick = onScanNowClick, + onSyncMetadataClick = onSyncMetadataClick, onChangeFolderClick = onSelectSyncFolderClick, onDisconnectClick = onDisconnectSyncFolderClick, isLoading = isLoading @@ -1147,6 +1169,16 @@ private fun LibraryListItem( Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { + if (item.sourceFolderUri != null) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.secondary + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( text = item.title ?: item.displayName, style = MaterialTheme.typography.titleMedium, @@ -1326,6 +1358,7 @@ private fun FolderSyncScreen( lastScanTime: Long?, onSelectFolderClick: () -> Unit, onScanNowClick: () -> Unit, + onSyncMetadataClick: () -> Unit, onChangeFolderClick: () -> Unit, onDisconnectClick: () -> Unit, isLoading: Boolean @@ -1334,9 +1367,10 @@ private fun FolderSyncScreen( if (syncedFolderUri == null) { EmptyState( - title = "Import files from a Folder", - message = "Select a folder on your device. Episteme will automatically find and import any new books you add to it.", + title = "Sync Local Folder", + message = "Connect a folder to create a live library. Episteme will automatically monitor your files for new additions and keep your reading progress and other book metadata in sync with your folder.", onSelectFileClick = onSelectFolderClick, + primaryButtonText = "Select Folder", modifier = Modifier.fillMaxSize() ) } else { @@ -1344,86 +1378,224 @@ private fun FolderSyncScreen( getDisplayPathFromUri(context, syncedFolderUri) } + // Calculate times + val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) } + val lastScanText = remember(lastScanTime) { - if (lastScanTime == null || lastScanTime == 0L) { - "Never scanned" - } else { - "Last scan: ${ - SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()).format( - Date(lastScanTime) - ) - }" + if (lastScanTime == null || lastScanTime == 0L) "Never" + else dateFormat.format(Date(lastScanTime)) + } + + val nextScanText = remember(lastScanTime) { + if (lastScanTime == null || lastScanTime == 0L) "Pending first scan..." + else { + // Adding 4 hours (4 * 60 * 60 * 1000) to match the Worker interval + val nextTime = lastScanTime + (4 * 60 * 60 * 1000) + dateFormat.format(Date(nextTime)) } } Column( modifier = Modifier .fillMaxSize() - .padding(horizontal = 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) ) { - Icon( - imageVector = Icons.Default.FolderSpecial, // Using a standard icon - contentDescription = "Synced Folder", - modifier = Modifier.size(80.dp), - tint = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Folder Sync is Active", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() // Add this modifier - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Monitoring folder:", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth() - ) - Text( - text = folderPath, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(8.dp)) - if (isLoading) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Scanning...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } else { - Text( - text = lastScanText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() // Add this modifier + // 1. Status Dashboard Card + androidx.compose.material3.ElevatedCard( + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh ) - } - Spacer(modifier = Modifier.height(32.dp)) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header Row with Status + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.FolderSpecial, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "Active Sync", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } - Button(onClick = onScanNowClick, enabled = !isLoading) { - Text("Scan for New Books") + // Status Indicator + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = androidx.compose.foundation.shape.CircleShape + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + "Scanning...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } else { + Box( + modifier = Modifier + .size(8.dp) + .background( + Color(0xFF4CAF50), // Green for active + androidx.compose.foundation.shape.CircleShape + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + "Monitoring", + style = MaterialTheme.typography.labelSmall + ) + } + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + + // Folder Path + Column { + Text( + text = "LOCATION", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = folderPath, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Times Grid + Row( + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "LAST CHECK", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = lastScanText, style = MaterialTheme.typography.bodySmall) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "NEXT AUTO SYNC", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = nextScanText, style = MaterialTheme.typography.bodySmall) + } + } + } } - Spacer(modifier = Modifier.height(12.dp)) - Button(onClick = onChangeFolderClick, enabled = !isLoading) { - Text("Change Folder") + + // 2. Main Actions + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Actions", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 4.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + androidx.compose.material3.FilledTonalButton( + onClick = onScanNowClick, + enabled = !isLoading, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.small + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scan Files") + } + + androidx.compose.material3.OutlinedButton( + onClick = onSyncMetadataClick, + enabled = !isLoading, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.small + ) { + Icon( + painter = painterResource(id = R.drawable.sync), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Sync Data") + } + } } - Spacer(modifier = Modifier.height(12.dp)) - TextButton(onClick = onDisconnectClick, enabled = !isLoading) { - Text("Disconnect Folder") + + Spacer(modifier = Modifier.weight(1f)) + + Column { + HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onChangeFolderClick, enabled = !isLoading) { + Text("Change Folder") + } + + TextButton( + onClick = onDisconnectClick, + enabled = !isLoading, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Disconnect") + } + } } } } diff --git a/app/src/main/java/com/aryan/reader/MainScreen.kt b/app/src/main/java/com/aryan/reader/MainScreen.kt index 1051324..127af1c 100644 --- a/app/src/main/java/com/aryan/reader/MainScreen.kt +++ b/app/src/main/java/com/aryan/reader/MainScreen.kt @@ -17,8 +17,11 @@ * * mail: epistemereader@gmail.com */ +// MainScreen.kt package com.aryan.reader +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager @@ -31,13 +34,15 @@ import androidx.compose.material3.Text import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource -import kotlinx.coroutines.launch -import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.launch sealed class BottomBarScreen(val route: String, val label: String, val iconResId: Int) { object Home : BottomBarScreen("home", "Home", R.drawable.home) @@ -55,6 +60,13 @@ fun MainScreen( windowSizeClass: WindowSizeClass, navController: NavHostController ) { + val context = LocalContext.current + + SideEffect { + val activity = context as? ComponentActivity + activity?.enableEdgeToEdge() + } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val viewingShelfName = uiState.viewingShelfName @@ -67,6 +79,12 @@ fun MainScreen( ) val scope = rememberCoroutineScope() + LaunchedEffect(uiState.mainScreenStartPage) { + if (pagerState.currentPage != uiState.mainScreenStartPage) { + pagerState.animateScrollToPage(uiState.mainScreenStartPage) + } + } + LaunchedEffect(pagerState.currentPage) { viewModel.setMainScreenPage(pagerState.currentPage) } diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt index e06b7ea..ffc6366 100644 --- a/app/src/main/java/com/aryan/reader/MainViewModel.kt +++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt @@ -17,6 +17,7 @@ * * mail: epistemereader@gmail.com */ +// MainViewModel.kt @file:Suppress("DEPRECATION") package com.aryan.reader @@ -39,7 +40,10 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager import com.aryan.reader.data.CloudflareRepository import com.aryan.reader.data.CustomFontEntity @@ -99,6 +103,8 @@ import java.util.concurrent.CancellationException import java.util.concurrent.TimeUnit private const val KEY_RENDER_MODE = "render_mode" +private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled" +private const val KEY_FOLDER_MIGRATION_COMPLETED = "folder_migration_completed_v2" data class BannerMessage(val message: String, val isError: Boolean = false) @@ -176,17 +182,19 @@ data class ReaderScreenState( val isAuthMenuExpanded: Boolean = false, val isProUser: Boolean = false, val isSyncEnabled: Boolean = false, + val isFolderSyncEnabled: Boolean = false, val bannerMessage: BannerMessage? = null, val deviceLimitState: DeviceLimitReachedState = DeviceLimitReachedState(), val isReplacingDevice: Boolean = false, val isRequestingDrivePermission: Boolean = false, val downloadingBookIds: Set = emptySet(), val uploadingBookIds: Set = emptySet(), - val pendingSyncUpdate: SyncUpdateInfo? = null, val syncedFolderUri: String? = null, val lastFolderScanTime: Long? = null, val hasUnreadFeedback: Boolean = false, val searchQuery: String = "", + val showFolderMigrationDialog: Boolean = false, + val isRefreshing: Boolean = false, ) open class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -263,6 +271,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio }, currentUser = authRepository.getSignedInUser(), isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false), + isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false), syncedFolderUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null), lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong( KEY_LAST_FOLDER_SCAN_TIME, @@ -278,10 +287,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _prefsUpdateFlow ) { internalState, recentFilesFromDb, _ -> val validContextualItems = internalState.contextualActionItems.filter { contextItem -> - recentFilesFromDb.any { dbItem -> - dbItem.uriString == contextItem.uriString - } - }.toSet() + recentFilesFromDb.any { dbItem -> + dbItem.uriString == contextItem.uriString + } + }.toSet() if (validContextualItems.size != internalState.contextualActionItems.size) { Timber.d( @@ -322,15 +331,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val shelvedBookIds = mutableSetOf() val shelvesFromPrefs = shelfNames.map { shelfName -> - val bookIds = prefs.getStringSet( - "$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet() - ) ?: emptySet() - shelvedBookIds.addAll(bookIds) - val booksForShelf = sortedRecentFiles.filter { - it.bookId in bookIds - } - Shelf(shelfName, booksForShelf) - }.sortedBy { it.name } + val bookIds = prefs.getStringSet( + "$KEY_SHELF_CONTENT_PREFIX$shelfName", emptySet() + ) ?: emptySet() + shelvedBookIds.addAll(bookIds) + val booksForShelf = sortedRecentFiles.filter { + it.bookId in bookIds + } + Shelf(shelfName, booksForShelf) + }.sortedBy { it.name } val unshelvedBooks = sortedRecentFiles.filter { it.bookId !in shelvedBookIds } val allShelves = shelvesFromPrefs + Shelf("Unshelved", unshelvedBooks) @@ -338,8 +347,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio val booksAvailableForAdding = if (internalState.isAddingBooksToShelf && internalState.viewingShelfName != null) { val currentShelfBooksUris = allShelves.find { - it.name == internalState.viewingShelfName - }?.books?.map { it.uriString }?.toSet() ?: emptySet() + it.name == internalState.viewingShelfName + }?.books?.map { it.uriString }?.toSet() ?: emptySet() when (internalState.addBooksSource) { AddBooksSource.UNSHELVED -> unshelvedBooks @@ -358,10 +367,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio booksAvailableForAdding = booksAvailableForAdding ) }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = ReaderScreenState() - ) + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = ReaderScreenState() + ) fun onSearchQueryChange(newQuery: String) { _internalState.update { it.copy(searchQuery = newQuery) } @@ -520,6 +529,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio remoteConfigRepository.init() + if (_internalState.value.syncedFolderUri != null) { + Timber.d("App Start: Triggering local folder metadata-only sync.") + syncFolderMetadata() + } + viewModelScope.launch { billingClientWrapper.initializeConnection() } viewModelScope.launch { @@ -569,15 +583,30 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } } + val folderUri = _internalState.value.syncedFolderUri + val migrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false) + + if (folderUri != null && !migrationCompleted) { + Timber.tag("FolderSync").d("First time after refactor: Showing migration dialog.") + _internalState.update { it.copy(showFolderMigrationDialog = true) } + } + } + + fun completeFolderMigration() { + Timber.tag("FolderSync").d("User accepted migration. Marking completed and starting scan.") + prefs.edit { putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true) } + _internalState.update { it.copy(showFolderMigrationDialog = false) } + + scanSyncedFolder() } private val fontsRepository = FontsRepository(appContext) val customFonts = fontsRepository.getAllFonts().stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = emptyList() - ) + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) private suspend fun syncFonts(userId: String) { Timber.d("Starting Font Sync...") @@ -652,12 +681,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(isLoading = true) } val result = fontsRepository.importFont(uri) result.onSuccess { font -> - if (uiState.value.isSyncEnabled) { - uploadNewFont(font) - } - }.onFailure { - showBanner("Failed to import font: ${it.message}", isError = true) + if (uiState.value.isSyncEnabled) { + uploadNewFont(font) } + }.onFailure { + showBanner("Failed to import font: ${it.message}", isError = true) + } _internalState.update { it.copy(isLoading = false) } } } @@ -1003,8 +1032,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } private fun uploadSingleBookMetadata(book: RecentFileItem) { - if (!uiState.value.isSyncEnabled) { - Timber.tag("AnnotationSync").d("Sync disabled. Skipping upload.") + if (!uiState.value.isSyncEnabled) return + + if (book.sourceFolderUri != null) { + Timber.d("Skipping metadata sync for local folder book: ${book.displayName}") return } val currentUser = uiState.value.currentUser ?: return @@ -1095,9 +1126,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } val metadataToSync = book.toBookMetadata().copy( - lastModifiedTimestamp = System.currentTimeMillis(), - hasAnnotations = hasAnyData - ) + lastModifiedTimestamp = System.currentTimeMillis(), + hasAnnotations = hasAnyData + ) firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId) Timber.tag("AnnotationSync") @@ -1184,102 +1215,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio isLoading = false, errorMessage = null, initialLocator = null, - initialPageInBook = null, - pendingSyncUpdate = null + initialPageInBook = null ) } bookToSync?.let { if (uiState.value.uploadingBookIds.contains(it.bookId)) { - Timber.d( - "Book closed, but initial upload is still in progress. Metadata will be synced upon upload completion." - ) return } - Timber.d("Book closed, triggering metadata sync for ${it.bookId}") - uploadSingleBookMetadata(it) - } - } + if (uiState.value.isSyncEnabled) { + Timber.d("Book closed, triggering metadata sync for ${it.bookId}") + uploadSingleBookMetadata(it) + } - private fun syncSingleBookMetadataOnOpen(bookId: String) { - if (!uiState.value.isSyncEnabled) return - val currentUser = uiState.value.currentUser ?: return - - viewModelScope.launch { - try { - val remoteBookMetadata = - firestoreRepository.getBookMetadata(currentUser.uid, bookId) ?: return@launch - val localBook = recentFilesRepository.getFileByBookId(bookId) - - val showUpdatePrompt = if (localBook == null) { - true - } else if (remoteBookMetadata.lastModifiedTimestamp > localBook.lastModifiedTimestamp) { - val remoteLocator = - if (remoteBookMetadata.lastChapterIndex != null && remoteBookMetadata.locatorBlockIndex != null && remoteBookMetadata.locatorCharOffset != null) { - Locator( - remoteBookMetadata.lastChapterIndex, - remoteBookMetadata.locatorBlockIndex, - remoteBookMetadata.locatorCharOffset - ) - } else null - - val localLocator = - if (localBook.lastChapterIndex != null && localBook.locatorBlockIndex != null && localBook.locatorCharOffset != null) { - Locator( - localBook.lastChapterIndex, - localBook.locatorBlockIndex, - localBook.locatorCharOffset - ) - } else null - - val positionChanged = when (localBook.type) { - FileType.PDF -> remoteBookMetadata.lastPage != localBook.lastPage - FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> remoteLocator != localLocator - } - - val bookmarksChanged = - remoteBookMetadata.bookmarksJson != localBook.bookmarksJson - - positionChanged || bookmarksChanged - } else { - false + if (it.sourceFolderUri != null) { + Timber.d("Book closed (Folder Linked), syncing metadata to folder: ${it.bookId}") + viewModelScope.launch { + recentFilesRepository.syncLocalMetadataToFolder(it.bookId) } - - if (showUpdatePrompt) { - Timber.d("Remote metadata is newer for $bookId. Proposing update to user.") - - recentFilesRepository.addRecentFile(remoteBookMetadata.toRecentFileItem()) - - val locator = - if (remoteBookMetadata.lastChapterIndex != null && remoteBookMetadata.locatorBlockIndex != null && remoteBookMetadata.locatorCharOffset != null) { - Locator( - remoteBookMetadata.lastChapterIndex, - remoteBookMetadata.locatorBlockIndex, - remoteBookMetadata.locatorCharOffset - ) - } else null - - _internalState.update { - it.copy( - pendingSyncUpdate = SyncUpdateInfo( - bookId = bookId, - locator = locator, - page = remoteBookMetadata.lastPage, - cfi = remoteBookMetadata.lastPositionCfi, - bookmarksJson = remoteBookMetadata.bookmarksJson - ) - ) - } - } else { - Timber.d( - "Local metadata is up-to-date for $bookId or remote data is identical. No prompt." - ) - if (localBook != null && remoteBookMetadata.lastModifiedTimestamp > localBook.lastModifiedTimestamp) { - recentFilesRepository.addRecentFile(remoteBookMetadata.toRecentFileItem()) - } - } - } catch (e: Exception) { - Timber.e(e, "Failed to sync single book metadata on open for bookId: $bookId") } } } @@ -1312,19 +1265,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION ) Timber.d("Persistable URI permission taken for folder: $folderUri") - prefs.edit { putString(KEY_SYNCED_FOLDER_URI, folderUri.toString()) } - _internalState.update { it.copy(syncedFolderUri = folderUri.toString()) } - // Trigger an initial scan + prefs.edit { + putString(KEY_SYNCED_FOLDER_URI, folderUri.toString()) + putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true) + } + + _internalState.update { it.copy( + syncedFolderUri = folderUri.toString(), + showFolderMigrationDialog = false + ) } + scanSyncedFolder() - // Schedule periodic sync val workManager = WorkManager.getInstance(appContext) val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build() val syncRequest = PeriodicWorkRequestBuilder(4, TimeUnit.HOURS).setConstraints( - constraints - ).build() + constraints + ).build() workManager.enqueueUniquePeriodicWork( FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.REPLACE, syncRequest @@ -1339,20 +1298,77 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun syncFolderMetadata() { + triggerFolderSyncWorker(metadataOnly = true) + } + + fun scanSyncedFolder() { + triggerFolderSyncWorker(metadataOnly = false) + } + + private fun triggerFolderSyncWorker(metadataOnly: Boolean) { + @Suppress("UnusedVariable", "Unused") val folderUriString = _internalState.value.syncedFolderUri ?: return + Timber.tag("FolderSync").d("Requesting folder sync (metadataOnly=$metadataOnly)") + + val workManager = WorkManager.getInstance(appContext) + val data = androidx.work.Data.Builder() + .putBoolean(FolderSyncWorker.KEY_METADATA_ONLY, metadataOnly) + .build() + + val request = OneTimeWorkRequestBuilder() + .setInputData(data) + .build() + + workManager.enqueueUniqueWork( + FolderSyncWorker.WORK_NAME_ONETIME, + ExistingWorkPolicy.REPLACE, + request + ) + + viewModelScope.launch { + workManager.getWorkInfoByIdFlow(request.id).collect { workInfo -> + if (workInfo != null) { + when (workInfo.state) { + WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED -> { + val msg = if (metadataOnly) "Folder Sync: Updating metadata..." else "Folder Sync: Scanning files..." + _internalState.update { it.copy(isLoading = true, bannerMessage = BannerMessage(msg)) } + } + WorkInfo.State.SUCCEEDED -> { + _internalState.update { it.copy( + isLoading = false, + isRefreshing = false, + bannerMessage = BannerMessage("Folder Sync: Scan complete."), + lastFolderScanTime = System.currentTimeMillis() + ) } + } + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + _internalState.update { it.copy(isLoading = false, isRefreshing = false, errorMessage = "Sync failed.") } // ADD isRefreshing = false + } + else -> Unit + } + } + } + } + } + fun disconnectSyncedFolder() { viewModelScope.launch { val folderUriString = _internalState.value.syncedFolderUri if (folderUriString != null) { + Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.") + recentFilesRepository.deleteFilesBySourceFolder(folderUriString) // New DAO method call + try { val uri = folderUriString.toUri() val contentResolver = appContext.contentResolver val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION contentResolver.releasePersistableUriPermission(uri, takeFlags) - Timber.d("Released persistable URI permission for folder: $uri") + Timber.tag("FolderSync").d("Released permission for: $uri") } catch (e: Exception) { - Timber.e(e, "Failed to release persistable URI permission for $folderUriString") + Timber.e(e, "Failed to release permission") } } + prefs.edit { remove(KEY_SYNCED_FOLDER_URI) remove(KEY_LAST_FOLDER_SCAN_TIME) @@ -1360,90 +1376,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) } WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME) - Timber.d("Cancelled folder sync worker.") - } - } - - fun scanSyncedFolder() { - val folderUriString = _internalState.value.syncedFolderUri ?: return - val folderUri = folderUriString.toUri() - - _internalState.update { - it.copy( - isLoading = true, bannerMessage = BannerMessage("Scanning folder for new books...") - ) - } - - viewModelScope.launch(Dispatchers.IO) { - val documentTree = DocumentFile.fromTreeUri(appContext, folderUri) - if (documentTree == null || !documentTree.isDirectory) { - withContext(Dispatchers.Main) { - _internalState.update { - it.copy( - isLoading = false, - errorMessage = "Could not read the synced folder. Please select it again." - ) - } - } - return@launch - } - - val filesToImport = mutableListOf() - val fileQueue = ArrayDeque() - documentTree.listFiles().let { fileQueue.addAll(it) } - - while (fileQueue.isNotEmpty()) { - val file = fileQueue.removeAt(0) - if (file.isDirectory) { - file.listFiles().let { fileQueue.addAll(it) } - } else if (file.isFile) { - val fileName = file.name ?: "" - if (fileName.endsWith(".pdf", true) || - fileName.endsWith(".epub", true) || - fileName.endsWith(".mobi", true) || - fileName.endsWith(".azw3", true) || - fileName.endsWith(".md", true) - ) { - filesToImport.add(file) - } - } - } - - var importedCount = 0 - for (file in filesToImport) { - val importResult = prepareBookForImport(file.uri) - if (importResult != null) { - val (internalUri, bookId, type) = importResult - val displayName = getFileNameFromUri(file.uri, appContext) ?: "Unknown File" - addFileToRecent( - internalUri, - type, - bookId, - customDisplayName = displayName, - isRecent = false, - sourceFolderUri = folderUriString - ) - importedCount++ - } - } - - val scanTime = System.currentTimeMillis() - prefs.edit { putLong(KEY_LAST_FOLDER_SCAN_TIME, scanTime) } - - withContext(Dispatchers.Main) { - val message = if (importedCount > 0) { - "Successfully imported $importedCount new book(s) from your folder." - } else { - "No new books found to import." - } - _internalState.update { - it.copy( - isLoading = false, - bannerMessage = BannerMessage(message), - lastFolderScanTime = scanTime - ) - } - } } } @@ -1543,10 +1475,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - fun clearPendingSyncUpdate() { - _internalState.update { it.copy(pendingSyncUpdate = null) } - } - fun deleteAllCloudAndLocalData() { if (!uiState.value.isSyncEnabled) { _internalState.update { it.copy(errorMessage = "Enable sync to clear cloud data.") } @@ -1730,10 +1658,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy( isLoading = false, deviceLimitState = DeviceLimitReachedState( - isLimitReached = true, - registeredDevices = deviceItems.sortedByDescending { item -> - item.lastSeen - })) + isLimitReached = true, + registeredDevices = deviceItems.sortedByDescending { item -> + item.lastSeen + })) } } ?: run { showBanner("Please sign in to test device management.", isError = true) @@ -1772,6 +1700,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } + fun setFolderSyncEnabled(enabled: Boolean) { + prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) } + _internalState.update { it.copy(isFolderSyncEnabled = enabled) } + + if (enabled && uiState.value.isSyncEnabled) { + viewModelScope.launch { syncWithCloud(showBanner = false) } + } + } + private fun syncWithCloud(showBanner: Boolean = false) = viewModelScope.launch { val hasPermissions = googleDriveRepository.hasDrivePermissions(appContext) val currentUser = _internalState.value.currentUser @@ -1785,7 +1722,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (showBanner) { _internalState.update { - it.copy(bannerMessage = BannerMessage("Syncing library and fonts...")) + it.copy(bannerMessage = BannerMessage("Cloud Sync: Checking for updates...")) } } @@ -1800,7 +1737,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio firestoreRepository.getAllShelves(currentUser.uid) } val localBooks = withContext(Dispatchers.IO) { - recentFilesRepository.getAllFilesForSync() + val allFiles = recentFilesRepository.getAllFilesForSync() + if (_internalState.value.isFolderSyncEnabled) { + allFiles + } else { + allFiles.filter { it.sourceFolderUri == null } + } } val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty() @@ -1830,8 +1772,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (local != null && remote != null) { Timber.tag("AnnotationSync").d( - "Checking $bookId. LocalTS: ${local.lastModifiedTimestamp}, RemoteTS: ${remote.lastModifiedTimestamp}, RemoteHasAnn: ${remote.hasAnnotations}" - ) + "Checking $bookId. LocalTS: ${local.lastModifiedTimestamp}, RemoteTS: ${remote.lastModifiedTimestamp}, RemoteHasAnn: ${remote.hasAnnotations}" + ) } when { @@ -1988,7 +1930,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio if (showBanner) { _internalState.update { it.copy( - isLoading = false, bannerMessage = BannerMessage("Sync complete.") + isLoading = false, bannerMessage = BannerMessage("Cloud Sync: Complete.") ) } } @@ -2087,7 +2029,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } } - private fun addFileToRecent( + private suspend fun addFileToRecent( uri: Uri, type: FileType, bookId: String, @@ -2095,110 +2037,108 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio customDisplayName: String? = null, isRecent: Boolean, sourceFolderUri: String? = null - ) { - viewModelScope.launch { - val isNewBook = withContext(Dispatchers.IO) { - recentFilesRepository.getFileByBookId(bookId) == null - } + ) = withContext(Dispatchers.IO) { + val isNewBook = withContext(Dispatchers.IO) { + recentFilesRepository.getFileByBookId(bookId) == null + } - val existingItem = recentFilesRepository.getFileByBookId(bookId) - val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri( - uri, - appContext - ) ?: "Unknown File" + val existingItem = recentFilesRepository.getFileByBookId(bookId) + val displayName = customDisplayName ?: existingItem?.displayName ?: getFileNameFromUri( + uri, + appContext + ) ?: "Unknown File" - var coverPath: String? = null - var title: String? = null - var author: String? = null - var bookForMetadata = epubBook + var coverPath: String? = null + var title: String? = null + var author: String? = null + var bookForMetadata = epubBook - if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) { - Timber.d("Parsing downloaded book for cover/metadata: $displayName") - try { - importMutex.withLock { - bookForMetadata = withContext(Dispatchers.IO) { - appContext.contentResolver.openInputStream(uri)?.use { inputStream -> - when (type) { - FileType.EPUB -> { - epubParser.createEpubBook( - inputStream = inputStream, - originalBookNameHint = displayName, - parseContent = false - ) - } + if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) { + Timber.d("Parsing downloaded book for cover/metadata: $displayName") + try { + importMutex.withLock { + bookForMetadata = withContext(Dispatchers.IO) { + appContext.contentResolver.openInputStream(uri)?.use { inputStream -> + when (type) { + FileType.EPUB -> { + epubParser.createEpubBook( + inputStream = inputStream, + originalBookNameHint = displayName, + parseContent = false + ) + } - FileType.MOBI -> { - mobiParser.createMobiBook( - inputStream = inputStream, originalBookNameHint = displayName - ) - } + FileType.MOBI -> { + mobiParser.createMobiBook( + inputStream = inputStream, originalBookNameHint = displayName + ) + } - else -> { - singleFileImporter.importSingleFile( - inputStream, - type, - originalBookNameHint = displayName - ) - } + else -> { + singleFileImporter.importSingleFile( + inputStream, + type, + originalBookNameHint = displayName + ) } } } } - } catch (e: Exception) { - Timber.e( - e, - "Failed to parse metadata for book: $displayName. Proceeding with basic info." - ) - bookForMetadata = null } + } catch (e: Exception) { + Timber.e( + e, + "Failed to parse metadata for book: $displayName. Proceeding with basic info." + ) + bookForMetadata = null + } + } + + val finalBookMetadata = bookForMetadata + + if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) { + title = finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName + + author = finalBookMetadata.author.takeIf { + it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } - val finalBookMetadata = bookForMetadata - - if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) { - title = finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName - - author = finalBookMetadata.author.takeIf { - it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) - } - - finalBookMetadata.coverImage?.let { cover -> - coverPath = recentFilesRepository.saveCoverToCache(cover, uri) - } - } else if (type == FileType.PDF) { - title = displayName - val pdfCoverGenerator = PdfCoverGenerator(appContext) - val coverBitmap = pdfCoverGenerator.generateCover(uri) - if (coverBitmap != null) { - coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) - } + finalBookMetadata.coverImage?.let { cover -> + coverPath = recentFilesRepository.saveCoverToCache(cover, uri) } - - val newLastModifiedTimestamp = - existingItem?.lastModifiedTimestamp ?: System.currentTimeMillis() - - val newItem = RecentFileItem( - bookId = bookId, - uriString = uri.toString(), - type = type, - displayName = displayName, - timestamp = System.currentTimeMillis(), - coverImagePath = coverPath, - title = title, - author = author, - isAvailable = true, - lastModifiedTimestamp = newLastModifiedTimestamp, - isDeleted = false, - isRecent = isRecent, - sourceFolderUri = sourceFolderUri - ) - recentFilesRepository.addRecentFile(newItem) - Timber.i("Added/Updated $displayName ($type) to recent files via repository.") - - if (isNewBook) { - uploadNewBookAndMetadata(newItem) + } else if (type == FileType.PDF) { + title = displayName + val pdfCoverGenerator = PdfCoverGenerator(appContext) + val coverBitmap = pdfCoverGenerator.generateCover(uri) + if (coverBitmap != null) { + coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri) } } + + val newLastModifiedTimestamp = + existingItem?.lastModifiedTimestamp ?: System.currentTimeMillis() + + val newItem = RecentFileItem( + bookId = bookId, + uriString = uri.toString(), + type = type, + displayName = displayName, + timestamp = System.currentTimeMillis(), + coverImagePath = coverPath, + title = title, + author = author, + isAvailable = true, + lastModifiedTimestamp = newLastModifiedTimestamp, + isDeleted = false, + isRecent = isRecent, + sourceFolderUri = sourceFolderUri + ) + recentFilesRepository.addRecentFile(newItem) + Timber.i("Added/Updated $displayName ($type) to recent files via repository.") + + if (isNewBook) { + uploadNewBookAndMetadata(newItem) + } } fun setSortOrder(sortOrder: SortOrder) { @@ -2555,9 +2495,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio ) { Timber.d("Saving EPUB position locally: URI=$uri, Locator=$locator") viewModelScope.launch { - recentFilesRepository.getFileByUri(uri.toString())?.let { + recentFilesRepository.getFileByUri(uri.toString())?.let { _ -> recentFilesRepository.updateEpubReadingPosition( - uri.toString(), locator, cfiForWebView, progress + uriString = uri.toString(), + locator = locator, + cfiForWebView = cfiForWebView, + progress = progress ) } } @@ -2600,15 +2543,51 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio } Timber.d("Saving PDF position locally: URI=$currentPdfUri, Page=$page") viewModelScope.launch { - recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { + recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ -> recentFilesRepository.updatePdfReadingPosition( - currentPdfUri.toString(), page, progress + uriString = currentPdfUri.toString(), + page = page, + progress = progress ) } } } } + fun refreshLibrary() { + val syncEnabled = _internalState.value.isSyncEnabled + val hasFolder = _internalState.value.syncedFolderUri != null // Check for URI instead of toggle + + if (!syncEnabled && !hasFolder) { + Timber.d("Refresh skipped: No sync methods active.") + _internalState.update { it.copy(isRefreshing = false) } // Ensure indicator retracts immediately + return + } + + viewModelScope.launch { + _internalState.update { it.copy(isRefreshing = true) } + + try { + if (syncEnabled) { + syncWithCloud(showBanner = false).join() + } + + if (hasFolder) { + // This triggers the worker which we observe above to clear isRefreshing + syncFolderMetadata() + } + } catch (e: Exception) { + Timber.e(e, "Refresh failed") + _internalState.update { it.copy(isRefreshing = false) } + } finally { + // If folder sync isn't running, we must close the indicator here + if (!hasFolder) { + _internalState.update { it.copy(isRefreshing = false) } + } + } + } + } + fun clearBookCache() { viewModelScope.launch { bookCacheDao.clearAllCache() @@ -2630,23 +2609,43 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio _internalState.update { it.copy(contextualActionItems = newSelection) } Timber.d("New selection size: ${newSelection.size}") } else { + if (item.sourceFolderUri != null && item.uriString != null) { + viewModelScope.launch { + val exists = try { + val uri = item.uriString.toUri() + DocumentFile.fromSingleUri(appContext, uri)?.exists() == true + } catch (_: Exception) { false } + + if (!exists) { + Timber.tag("FolderSync").i("LazyCleanup: File ${item.displayName} missing. Removing.") + recentFilesRepository.deleteFilePermanently(listOf(item.bookId)) + showBanner("File deleted from folder. Removed from library.") + return@launch + } + + Timber.d("Recent file clicked (opening): ${item.displayName}") + if (item.isAvailable) { + item.getUri()?.let { uri -> + openBook(uri, item.bookId, item.type, item.displayName) + } ?: run { + _internalState.update { it.copy(errorMessage = "Could not find file location.") } + } + } else { + downloadBook(item, openWhenComplete = true) + } + } + return + } + Timber.d("Recent file clicked (opening): ${item.displayName}") if (item.isAvailable) { item.getUri()?.let { uri -> openBook(uri, item.bookId, item.type, item.displayName) } ?: run { - _internalState.update { - it.copy( - errorMessage = "Could not find file location for ${item.displayName}." - ) - } + _internalState.update { it.copy(errorMessage = "Could not find file location.") } return } - syncSingleBookMetadataOnOpen(item.bookId) } else { - Timber.w( - "Clicked on a book that is not available locally: ${item.displayName}, starting download." - ) downloadBook(item, openWhenComplete = true) } } @@ -3035,78 +3034,133 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio fun deleteContextualItemsPermanently() { val itemsToRemove = _internalState.value.contextualActionItems if (itemsToRemove.isNotEmpty()) { - val canSync = uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext) - _internalState.update { it.copy(contextualActionItems = emptySet()) } viewModelScope.launch { - val currentUser = uiState.value.currentUser + val canSync = uiState.value.isSyncEnabled && googleDriveRepository.hasDrivePermissions(appContext) - if (canSync && currentUser != null) { - _internalState.update { - it.copy( - isLoading = true, - bannerMessage = BannerMessage("Deleting from all devices...") - ) - } - try { - val accessToken = googleDriveRepository.getAccessToken(appContext) ?: throw Exception("No token") - val deviceId = getInstallationId() - val remoteFiles = withContext(Dispatchers.IO) { - googleDriveRepository.getFiles(accessToken)?.files.orEmpty() - .associateBy { it.name } + val (folderBooks, managedBooks) = itemsToRemove.partition { it.sourceFolderUri != null } + + if (folderBooks.isNotEmpty()) { + Timber.d("Processing ${folderBooks.size} folder books for deletion.") + + val idsToDeleteLocally = mutableListOf() + + folderBooks.forEach { item -> + idsToDeleteLocally.add(item.bookId) + pdfTextRepository.clearBookText(item.bookId) + + if (item.uriString != null) { + try { + val fileUri = item.uriString.toUri() + val fileDoc = DocumentFile.fromSingleUri(appContext, fileUri) + if (fileDoc != null && fileDoc.exists()) { + if (fileDoc.delete()) { + Timber.i("Physically deleted folder file: ${item.displayName}") + } else { + Timber.e("Failed to delete folder file via SAF: ${item.displayName}") + } + } + } catch (e: Exception) { + Timber.e(e, "Error deleting physical file for ${item.bookId}") + } } - for (item in itemsToRemove) { - recentFilesRepository.markAsDeleted(listOf(item.bookId)) - pdfTextRepository.clearBookText(item.bookId) - val deletedItem = - recentFilesRepository.getFileByBookId(item.bookId) ?: continue + // 2. Try to delete the metadata JSON (.bookId.json) + if (item.sourceFolderUri != null) { + try { + val rootUri = item.sourceFolderUri.toUri() + val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri) + val syncDir = rootDoc?.findFile("episteme") ?: rootDoc?.findFile(".episteme") - firestoreRepository.syncBookMetadata( - currentUser.uid, deletedItem.toBookMetadata(), deviceId + if (syncDir != null) { + // Try hidden first, then legacy + val metaFile = syncDir.findFile(".${item.bookId}.json") + ?: syncDir.findFile("${item.bookId}.json") + + metaFile?.delete() + } + } catch (e: Exception) { + Timber.e(e, "Error deleting metadata file for ${item.bookId}") + } + } + } + + recentFilesRepository.deleteFilePermanently(idsToDeleteLocally) + } + + if (managedBooks.isNotEmpty()) { + val currentUser = uiState.value.currentUser + + if (canSync && currentUser != null) { + _internalState.update { + it.copy( + isLoading = true, + bannerMessage = BannerMessage("Deleting from all devices...") ) + } + try { + val accessToken = googleDriveRepository.getAccessToken(appContext) + ?: throw Exception("No token") + val deviceId = getInstallationId() - val fileExtension = item.type.name.lowercase() - val fileName = "${item.bookId}.$fileExtension" - remoteFiles[fileName]?.id?.let { fileId -> - Timber.d("Deleting from Drive: $fileName") - googleDriveRepository.deleteDriveFile(accessToken, fileId) + val remoteFiles = withContext(Dispatchers.IO) { + googleDriveRepository.getFiles(accessToken)?.files.orEmpty() + .associateBy { it.name } } - recentFilesRepository.deleteFilePermanently(listOf(item.bookId)) - } - _internalState.update { - it.copy( - isLoading = false, - bannerMessage = BannerMessage("Deletion complete.") - ) - } - } catch (e: Exception) { - Timber.e(e, "Error during permanent deletion") + for (item in managedBooks) { + recentFilesRepository.markAsDeleted(listOf(item.bookId)) + pdfTextRepository.clearBookText(item.bookId) - recentFilesRepository.deleteFilePermanently(itemsToRemove.map { it.bookId }) - itemsToRemove.forEach { item -> - pdfTextRepository.clearBookText(item.bookId) - } + firestoreRepository.syncBookMetadata( + currentUser.uid, item.toBookMetadata().copy(isDeleted = true), deviceId + ) - _internalState.update { - it.copy( - isLoading = false, - errorMessage = "Cloud sync failed, deleted locally." - ) + val fileExtension = item.type.name.lowercase() + val fileName = "${item.bookId}.$fileExtension" + remoteFiles[fileName]?.id?.let { fileId -> + Timber.d("Deleting from Drive: $fileName") + googleDriveRepository.deleteDriveFile(accessToken, fileId) + } + + recentFilesRepository.deleteFilePermanently(listOf(item.bookId)) + } + + _internalState.update { + it.copy(isLoading = false, bannerMessage = BannerMessage("Deletion complete.")) + } + } catch (e: Exception) { + Timber.e(e, "Error during permanent deletion") + recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) + managedBooks.forEach { item -> + pdfTextRepository.clearBookText(item.bookId) + } + _internalState.update { + it.copy(isLoading = false, errorMessage = "Cloud sync failed, deleted locally.") + } } + } else { + recentFilesRepository.deleteFilePermanently(managedBooks.map { it.bookId }) + managedBooks.forEach { item -> pdfTextRepository.clearBookText(item.bookId) } } - } else { - recentFilesRepository.deleteFilePermanently(itemsToRemove.map { it.bookId }) - itemsToRemove.forEach { item -> pdfTextRepository.clearBookText(item.bookId) } } + + val totalRemoved = folderBooks.size + managedBooks.size + _internalState.update { it.copy(isLoading = false, bannerMessage = BannerMessage("$totalRemoved books removed from library.")) } } } else { Timber.w("Attempted to remove contextual items, but none were selected.") } } + fun navigateToFolderSync() { + // 1. Switch MainScreen to Library Tab (Index 1) + setMainScreenPage(1) + // 2. Switch LibraryScreen to Folder Tab (Index 2) + setLibraryScreenPage(2) + } + override fun onCleared() { super.onCleared() prefs.unregisterOnSharedPreferenceChangeListener(prefsListener) diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt new file mode 100644 index 0000000..1c68dfd --- /dev/null +++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt @@ -0,0 +1,115 @@ +// MetadataExtractionWorker.kt +package com.aryan.reader + +import android.content.Context +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.aryan.reader.data.RecentFilesRepository +import com.aryan.reader.epub.EpubParser +import com.aryan.reader.epub.MobiParser +import com.aryan.reader.pdf.PdfCoverGenerator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber + +class MetadataExtractionWorker( + private val appContext: Context, + workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + private val recentFilesRepository = RecentFilesRepository(appContext) + private val epubParser = EpubParser(appContext) + private val mobiParser = MobiParser(appContext) + private val pdfCoverGenerator = PdfCoverGenerator(appContext) + + companion object { + const val WORK_NAME = "MetadataExtractionWorker" + } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + // Fetch all books that are from a folder but don't have a cover yet (implies metadata likely missing/basic) + val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers() + + if (filesToProcess.isEmpty()) { + return@withContext Result.success() + } + + Timber.tag("MetadataWorker").i("Starting background metadata extraction for ${filesToProcess.size} books.") + + filesToProcess.forEach { item -> + if (isStopped) return@forEach + + try { + val uri = item.uriString?.toUri() ?: return@forEach + val type = item.type + + var coverPath: String? = null + var title: String? = null + var author: String? = null + + // We open the stream briefly to extract metadata + appContext.contentResolver.openInputStream(uri)?.use { inputStream -> + when (type) { + FileType.EPUB -> { + val book = epubParser.createEpubBook( + inputStream = inputStream, + originalBookNameHint = item.displayName, + parseContent = false + ) + book.let { + title = it.title.takeIf { t -> t.isNotBlank() } + author = it.author.takeIf { a -> a.isNotBlank() } + it.coverImage?.let { img -> + coverPath = recentFilesRepository.saveCoverToCache(img, uri) + } + } + } + FileType.MOBI -> { + val book = mobiParser.createMobiBook( + inputStream = inputStream, + originalBookNameHint = item.displayName + ) + book?.let { + title = it.title.takeIf { t -> t.isNotBlank() } + author = it.author.takeIf { a -> a.isNotBlank() } + it.coverImage?.let { img -> + coverPath = recentFilesRepository.saveCoverToCache(img, uri) + } + } + } + FileType.PDF -> { + // PDF cover generation is heavy, but necessary + pdfCoverGenerator.generateCover(uri)?.let { + coverPath = recentFilesRepository.saveCoverToCache(it, uri) + } + title = item.displayName.substringBeforeLast(".") // Clean filename + } + else -> { /* Text/MD files usually don't have covers */ } + } + } + + // Only update if we actually found something useful + if (coverPath != null || title != null || author != null) { + val updatedItem = item.copy( + coverImagePath = coverPath ?: item.coverImagePath, + title = title ?: item.title ?: item.displayName, + author = author ?: item.author + ) + recentFilesRepository.addRecentFile(updatedItem) + Timber.tag("MetadataWorker").d("Updated metadata for: ${item.displayName}") + } + + } catch (e: Exception) { + Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}") + } + } + + return@withContext Result.success() + } catch (e: Exception) { + Timber.tag("MetadataWorker").e(e, "Metadata extraction failed") + return@withContext Result.failure() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt index 4cebce4..ce79060 100644 --- a/app/src/main/java/com/aryan/reader/SharedComposables.kt +++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt @@ -86,6 +86,7 @@ import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.clickable import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.OutlinedButton import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -261,20 +262,43 @@ fun CustomTopAppBar( } @Composable -fun DeleteConfirmationDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit, isPermanentDelete: Boolean = false) { +fun DeleteConfirmationDialog( + count: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + isPermanentDelete: Boolean = false, + containsFolderItems: Boolean = false // New parameter +) { val title = if (isPermanentDelete) "Delete File(s) Permanently" else "Remove from Recents" + val text = if (isPermanentDelete) { - "Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone." + if (containsFolderItems) { + "Warning: Some selected items are synced from a local folder. Proceeding will delete the actual files from your device storage.\n\nThis action cannot be undone." + } else { + "Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone." + } } else { "Do you want to remove $count selected file(s) from the recent files list? It will reappear if you open it again from the library." } + val confirmText = if (isPermanentDelete) "Delete" else "Remove" + AlertDialog( onDismissRequest = onDismiss, title = { Text(title) }, - text = { Text(text) }, + text = { + Text( + text, + color = if (containsFolderItems && isPermanentDelete) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant + ) + }, confirmButton = { - TextButton(onClick = onConfirm) { Text(confirmText) } + TextButton( + onClick = onConfirm, + colors = if (containsFolderItems && isPermanentDelete) ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) else ButtonDefaults.textButtonColors() + ) { + Text(confirmText) + } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } @@ -430,7 +454,10 @@ fun EmptyState( title: String, message: String, onSelectFileClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + primaryButtonText: String = "Select a File", + secondaryButtonText: String? = null, + onSecondaryClick: (() -> Unit)? = null ) { Column( modifier = modifier @@ -449,7 +476,8 @@ fun EmptyState( Text( text = title, style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(8.dp)) Text( @@ -459,7 +487,15 @@ fun EmptyState( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(32.dp)) - SelectFileButton(onClick = onSelectFileClick, text = "Select a File") + + SelectFileButton(onClick = onSelectFileClick, text = primaryButtonText) + + if (secondaryButtonText != null && onSecondaryClick != null) { + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton(onClick = onSecondaryClick) { + Text(secondaryButtonText) + } + } } } diff --git a/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt new file mode 100644 index 0000000..0e2a92b --- /dev/null +++ b/app/src/main/java/com/aryan/reader/data/FolderBookMetadata.kt @@ -0,0 +1,102 @@ +// FolderBookMetadata.kt +package com.aryan.reader.data + +import com.aryan.reader.FileType +import org.json.JSONObject + +data class FolderBookMetadata( + val bookId: String, + val title: String?, + val author: String?, + val displayName: String, + val type: String, + val lastChapterIndex: Int?, + val lastPage: Int?, + val lastPositionCfi: String?, + val progressPercentage: Float, + val isRecent: Boolean, + // REMOVED: val isDeleted: Boolean, + val lastModifiedTimestamp: Long, + val bookmarksJson: String?, + val locatorBlockIndex: Int?, + val locatorCharOffset: Int? +) { + fun toJsonString(): String { + val json = JSONObject() + json.put("bookId", bookId) + json.put("title", title) + json.put("author", author) + json.put("displayName", displayName) + json.put("type", type) + json.put("lastChapterIndex", lastChapterIndex ?: -1) + json.put("lastPage", lastPage ?: -1) + json.put("lastPositionCfi", lastPositionCfi) + json.put("progressPercentage", progressPercentage.toDouble()) + json.put("isRecent", isRecent) + // REMOVED: json.put("isDeleted", isDeleted) + json.put("lastModifiedTimestamp", lastModifiedTimestamp) + json.put("bookmarksJson", bookmarksJson) + json.put("locatorBlockIndex", locatorBlockIndex ?: -1) + json.put("locatorCharOffset", locatorCharOffset ?: -1) + return json.toString() + } + + companion object { + fun fromJsonString(jsonString: String): FolderBookMetadata { + val json = JSONObject(jsonString) + + fun JSONObject.optStringNull(key: String): String? { + return if (has(key) && !isNull(key)) getString(key) else null + } + + fun JSONObject.optIntNull(key: String): Int? { + val value = optInt(key, -1) + return if (value == -1) null else value + } + + return FolderBookMetadata( + bookId = json.getString("bookId"), + title = json.optStringNull("title"), + author = json.optStringNull("author"), + displayName = json.optString("displayName", "Unknown"), + type = json.optString("type", "PDF"), + lastChapterIndex = json.optIntNull("lastChapterIndex"), + lastPage = json.optIntNull("lastPage"), + lastPositionCfi = json.optStringNull("lastPositionCfi"), + progressPercentage = json.optDouble("progressPercentage", 0.0).toFloat(), + isRecent = json.optBoolean("isRecent", true), + // REMOVED: isDeleted deserialization + lastModifiedTimestamp = json.optLong("lastModifiedTimestamp", 0L), + bookmarksJson = json.optStringNull("bookmarksJson"), + locatorBlockIndex = json.optIntNull("locatorBlockIndex"), + locatorCharOffset = json.optIntNull("locatorCharOffset") + ) + } + } +} + +// Update the converter +fun FolderBookMetadata.toRecentFileItem(uriString: String?, coverPath: String?, sourceFolderUri: String?): RecentFileItem { + return RecentFileItem( + bookId = this.bookId, + uriString = uriString, + type = try { FileType.valueOf(this.type) } catch (_: Exception) { FileType.EPUB }, + displayName = this.displayName, + timestamp = System.currentTimeMillis(), + coverImagePath = coverPath, + title = this.title, + author = this.author, + lastChapterIndex = this.lastChapterIndex, + lastPage = this.lastPage, + lastPositionCfi = this.lastPositionCfi, + locatorBlockIndex = this.locatorBlockIndex, + locatorCharOffset = this.locatorCharOffset, + progressPercentage = this.progressPercentage, + isRecent = this.isRecent, + isAvailable = true, + lastModifiedTimestamp = this.lastModifiedTimestamp, + isDeleted = false, // ALWAYS FALSE for folder sync + bookmarksJson = this.bookmarksJson, + sourceFolderUri = sourceFolderUri + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt new file mode 100644 index 0000000..e1d212c --- /dev/null +++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt @@ -0,0 +1,260 @@ +// LocalSyncUtils.kt +package com.aryan.reader.data + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber + +object LocalSyncUtils { + private const val SYNC_DIR_NAME = "episteme" + private const val TAG = "FolderSync" + + suspend fun saveMetadataToFolder( + context: Context, + sourceFolderUri: Uri, + metadata: FolderBookMetadata + ) = withContext(Dispatchers.IO) { + try { + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext + + val syncDir = getOrCreateSyncDir(rootTree) + + if (syncDir == null) { + Timber.tag(TAG).e("Could not create/find $SYNC_DIR_NAME directory in $sourceFolderUri") + return@withContext + } + + // Ensure .nomedia exists to prevent gallery clutter + ensureNoMedia(syncDir) + + // Use hidden filename to avoid "Recents" clutter + val hiddenFileName = ".${metadata.bookId}.json" + val legacyFileName = "${metadata.bookId}.json" + + // Check for existing files (Hidden OR Legacy) + val existingHidden = syncDir.findFile(hiddenFileName) + val existingLegacy = syncDir.findFile(legacyFileName) + + // Prefer hidden, fallback to legacy for conflict check + val existingFile = existingHidden ?: existingLegacy + + if (existingFile != null && existingFile.exists()) { + try { + val existingContent = context.contentResolver.openInputStream(existingFile.uri)?.use { input -> + input.bufferedReader().use { it.readText() } + } + + if (existingContent != null) { + val existingMeta = FolderBookMetadata.fromJsonString(existingContent) + val diff = existingMeta.lastModifiedTimestamp - metadata.lastModifiedTimestamp + + // Clobber Protection + if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) { + Timber.tag(TAG).w("ClobberCheck: ABORTING save for ${metadata.bookId}. Folder has newer data.") + return@withContext + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to read existing metadata for conflict check") + } + + // Delete the existing file (whether hidden or legacy) before writing new one + try { + existingFile.delete() + } catch (e: Exception) { + Timber.tag(TAG).w("Failed to delete existing metadata file: ${e.message}") + } + } + + // If we had a legacy file that wasn't the 'existingFile' (edge case), delete it too + if (existingLegacy != null && existingLegacy.exists()) { + try { existingLegacy.delete() } catch (_: Exception) {} + } + + val newFile = syncDir.createFile("application/json", hiddenFileName) + if (newFile == null) { + Timber.tag(TAG).e("Could not create metadata file for ${metadata.bookId}") + return@withContext + } + + val jsonString = metadata.toJsonString() + + try { + context.contentResolver.openOutputStream(newFile.uri)?.use { output -> + output.write(jsonString.toByteArray()) + } + Timber.tag(TAG).d("Saved metadata for ${metadata.bookId} (Hidden)") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to write content to metadata file for ${metadata.bookId}") + try { newFile.delete() } catch (_: Exception) {} + } + + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to save local metadata to folder.") + } + } + + suspend fun getBookMetadata( + context: Context, + sourceFolderUri: Uri, + bookId: String + ): FolderBookMetadata? = withContext(Dispatchers.IO) { + try { + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null + val syncDir = findSyncDir(rootTree) ?: return@withContext null + + // Find all related files: hidden, legacy, and conflicts + val relatedFiles = syncDir.listFiles().filter { file -> + val name = file.name ?: "" + // Match: .bookId.json, bookId.json, or containing .sync-conflict + (name.contains(bookId)) && (name.endsWith(".json") || name.contains(".sync-conflict")) + } + + if (relatedFiles.isEmpty()) return@withContext null + + return@withContext resolveAndCleanConflicts(context, relatedFiles, bookId) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error resolving book metadata for $bookId") + } + return@withContext null + } + + /** + * Reads all candidate files, picks the winner (highest timestamp), + * and deletes the losers (cleanup). + */ + private fun resolveAndCleanConflicts( + context: Context, + files: List, + bookId: String + ): FolderBookMetadata? { + var bestMeta: FolderBookMetadata? = null + var bestFile: DocumentFile? = null + + // 1. Find the winner + files.forEach { file -> + try { + val jsonString = context.contentResolver.openInputStream(file.uri)?.use { input -> + input.bufferedReader().use { it.readText() } + } + if (jsonString != null) { + val meta = FolderBookMetadata.fromJsonString(jsonString) + // Ensure this file actually belongs to the book (defensive check against partial name matches) + if (meta.bookId == bookId) { + if (bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp) { + bestMeta = meta + bestFile = file + } + } + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse conflict file: ${file.name}") + } + } + + // 2. Clean up losers + if (bestMeta != null && bestFile != null) { + val filesToDelete = files.filter { it.uri != bestFile!!.uri } + + if (filesToDelete.isNotEmpty()) { + Timber.tag(TAG).i("Resolving conflicts for $bookId. Winner: ${bestFile!!.name}. Deleting ${filesToDelete.size} obsolete files.") + filesToDelete.forEach { + try { it.delete() } catch(_: Exception) {} + } + } + + // 3. Migrate Legacy to Hidden if needed + val winnerName = bestFile!!.name ?: "" + if (!winnerName.startsWith(".")) { + Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName") + // We can't always rename easily with DocumentFile, so we allow 'saveMetadataToFolder' + // to handle the actual file swap next time a write happens, OR we could force a rewrite. + // For now, we leave it. The clutter is reduced by deleting conflicts. + // The next save operation will create the hidden file and delete this one. + } + } + + return bestMeta + } + + suspend fun getAllFolderMetadata( + context: Context, + sourceFolderUri: Uri + ): Map = withContext(Dispatchers.IO) { + val finalResults = mutableMapOf() + + try { + val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults + val syncDir = findSyncDir(rootTree) ?: return@withContext finalResults + + // Ensure .nomedia exists while scanning + ensureNoMedia(syncDir) + + val allFiles = syncDir.listFiles() + + // Group files by bookId. + // Filename formats: + // 1. hidden: .[bookId].json + // 2. legacy: [bookId].json + // 3. conflict: .[bookId].sync-conflict... or [bookId].sync-conflict... + val groupedFiles = allFiles + .filter { it.name?.endsWith(".json") == true || it.name?.contains(".sync-conflict") == true } + .groupBy { file -> + var name = file.name ?: "" + + // Remove leading dot + if (name.startsWith(".")) name = name.substring(1) + + // Remove conflict suffix + name = name.substringBefore(".sync-conflict") + + // Remove extension + name.substringBefore(".json") + } + + groupedFiles.forEach { (bookId, files) -> + val winner = resolveAndCleanConflicts(context, files, bookId) + if (winner != null) { + finalResults[bookId] = winner + } + } + + Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records.") + + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Error scanning .episteme folder") + } + return@withContext finalResults + } + + private fun findSyncDir(root: DocumentFile): DocumentFile? { + // Look for exact match first + val standardDir = root.findFile(SYNC_DIR_NAME) + if (standardDir != null && standardDir.isDirectory) return standardDir + + // Fallback search + val files = root.listFiles() + return files.firstOrNull { + it.isDirectory && (it.name == SYNC_DIR_NAME) + } + } + + private fun getOrCreateSyncDir(root: DocumentFile): DocumentFile? { + val existing = findSyncDir(root) + if (existing != null) return existing + return root.createDirectory(SYNC_DIR_NAME) + } + + private fun ensureNoMedia(dir: DocumentFile) { + if (dir.findFile(".nomedia") == null) { + try { + dir.createFile("application/octet-stream", ".nomedia") + } catch (e: Exception) { + Timber.tag(TAG).w("Failed to create .nomedia file") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt index 47f882b..8fae6dc 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt @@ -17,6 +17,7 @@ * * mail: epistemereader@gmail.com */ +// RecentFileDao.kt package com.aryan.reader.data import androidx.room.Dao @@ -33,6 +34,9 @@ interface RecentFileDao { @Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") fun getRecentFiles(): Flow> + @Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0") + suspend fun getFilesBySourceFolder(sourceFolderUri: String): List + @Query("SELECT * FROM recent_files") suspend fun getAllFiles(): List @@ -57,6 +61,12 @@ interface RecentFileDao { @Query("SELECT * FROM recent_files WHERE uriString = :uriString") suspend fun getFileByUri(uriString: String): RecentFileEntity? + @Query("DELETE FROM recent_files WHERE sourceFolderUri = :sourceFolderUri") + suspend fun deleteFilesBySourceFolder(sourceFolderUri: String) + + @Query("SELECT * FROM recent_files WHERE bookId LIKE :prefix || '%'") + suspend fun getFilesWithIdPrefix(prefix: String): List + @Query("DELETE FROM recent_files") suspend fun clearAll() @@ -74,4 +84,7 @@ interface RecentFileDao { @Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)") suspend fun markAsNotRecent(bookIds: List, timestamp: Long) + + @Query("SELECT * FROM recent_files WHERE sourceFolderUri IS NOT NULL AND coverImagePath IS NULL AND isDeleted = 0") + suspend fun getFolderBooksWithoutCovers(): List } \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt index 4d22678..9bfe398 100644 --- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt +++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt @@ -17,11 +17,13 @@ * * mail: epistemereader@gmail.com */ +// RecentFilesRepository.kt package com.aryan.reader.data import android.content.Context import android.graphics.Bitmap import android.net.Uri +import androidx.core.net.toUri import timber.log.Timber import com.aryan.reader.BookImporter import com.aryan.reader.paginatedreader.Locator @@ -34,7 +36,7 @@ import java.io.FileOutputStream private const val COVER_CACHE_DIR = "cover_cache" -class RecentFilesRepository(context: Context) { +class RecentFilesRepository(private val context: Context) { private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao() private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR) @@ -60,6 +62,10 @@ class RecentFilesRepository(context: Context) { return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem() } + suspend fun getFilesBySourceFolder(sourceFolderUri: String): List = withContext(Dispatchers.IO) { + return@withContext recentFileDao.getFilesBySourceFolder(sourceFolderUri).map { it.toRecentFileItem() } + } + suspend fun getAllFilesForSync(): List = withContext(Dispatchers.IO) { return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() } } @@ -109,6 +115,42 @@ class RecentFilesRepository(context: Context) { Timber.d("Added/Updated recent file in DB: ${item.displayName}") } + suspend fun syncLocalMetadataToFolder(bookId: String) = withContext(Dispatchers.IO) { + val entity = recentFileDao.getFileByBookId(bookId) ?: return@withContext + val folderUriString = entity.sourceFolderUri + + if (folderUriString != null) { + Timber.d("Syncing metadata to local folder for book: $bookId") + + val metadata = FolderBookMetadata( + bookId = entity.bookId, + title = entity.title, + author = entity.author, + displayName = entity.displayName, + type = entity.type.name, + lastChapterIndex = entity.lastChapterIndex, + lastPage = entity.lastPage, + lastPositionCfi = entity.lastPositionCfi, + progressPercentage = entity.progressPercentage ?: 0f, + isRecent = entity.isRecent, + lastModifiedTimestamp = entity.lastModifiedTimestamp, + bookmarksJson = entity.bookmarks, + locatorBlockIndex = entity.locatorBlockIndex, + locatorCharOffset = entity.locatorCharOffset + ) + + LocalSyncUtils.saveMetadataToFolder( + context = context, // Now correctly references the property + sourceFolderUri = folderUriString.toUri(), + metadata = metadata + ) + } + } + + suspend fun deleteFilesBySourceFolder(folderUriString: String) = withContext(Dispatchers.IO) { + recentFileDao.deleteFilesBySourceFolder(folderUriString) + } + suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) { val item = recentFileDao.getFileByUri(uriString) if (item != null) { @@ -126,6 +168,10 @@ class RecentFilesRepository(context: Context) { } } + suspend fun getFolderBooksWithoutCovers(): List = withContext(Dispatchers.IO) { + return@withContext recentFileDao.getFolderBooksWithoutCovers().map { it.toRecentFileItem() } + } + suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) { val currentTime = System.currentTimeMillis() recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime) @@ -171,7 +217,11 @@ class RecentFilesRepository(context: Context) { Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.") itemsToRemove.forEach { item -> item.coverImagePath?.let { deleteCachedCover(it) } - item.uriString?.let { bookImporter.deleteBookByUriString(it) } + try { + item.uriString?.let { bookImporter.deleteBookByUriString(it) } + } catch (e: Exception) { + Timber.w("DeleteDebug: Physical file deletion failed (likely already gone) for ${item.bookId}: ${e.message}") + } } recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId }) Timber.d("Permanently removed recent files from DB.") diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index bbb3013..43681ef 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -67,6 +67,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -174,11 +175,11 @@ class ContentBridge( @Suppress("unused") class CfiJsBridge( private val onCfiReady: (String) -> Unit, - private val onCfiForBookmarkReady: (String) -> Unit + private val onCfiForBookmarkReady: (String) -> Unit, + private val onScrollFinishedCallback: (Boolean) -> Unit ) { @JavascriptInterface fun onCfiExtracted(jsonResponse: String) { - // This is called from JavaScript with the generated CFI and diagnostics try { val json = JSONObject(jsonResponse) val cfi = json.optString("cfi", "/4") @@ -200,13 +201,12 @@ class CfiJsBridge( } } catch (e: Exception) { Timber.e(e, "Error parsing CFI JSON response: $jsonResponse") - // Still call back with a fallback CFI so the app doesn't hang onCfiReady("/4") } } + @JavascriptInterface fun onCfiForBookmarkExtracted(jsonResponse: String) { - // This is called from JavaScript with the generated CFI for a bookmark action try { val json = JSONObject(jsonResponse) val cfi = json.optString("cfi") @@ -230,6 +230,12 @@ class CfiJsBridge( Timber.e(e, "Error parsing CFI JSON for bookmark: $jsonResponse") } } + + @JavascriptInterface + fun onScrollFinished(success: Boolean) { + Timber.tag("BookmarkDiagnosis").d("JS reported scroll finished. Success: $success") + onScrollFinishedCallback(success) + } } @Suppress("unused") @@ -303,6 +309,7 @@ fun ChapterWebView( currentFontSize: Float, currentLineHeight: Float, onChapterInitiallyScrolled: () -> Unit, + modifier: Modifier = Modifier, onTap: () -> Unit, onPotentialScroll: () -> Unit, onOverScrollTop: (dragAmount: Float) -> Unit, @@ -314,9 +321,9 @@ fun ChapterWebView( onCfiGenerated: (cfi: String) -> Unit, onBookmarkCfiGenerated: (cfi: String) -> Unit, onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit, + onScrollFinished: (Boolean) -> Unit = {}, ttsScope: CoroutineScope, tocFragments: List, - modifier: Modifier = Modifier, initialFragmentId: String? = null, onTtsTextReady: suspend (String) -> Unit, isProUser: Boolean, @@ -347,6 +354,11 @@ fun ChapterWebView( var showPaletteManager by remember { mutableStateOf(false) } + val currentOnSnippetForBookmarkReady by rememberUpdatedState(onSnippetForBookmarkReady) + val currentOnCfiGenerated by rememberUpdatedState(onCfiGenerated) + val currentOnBookmarkCfiGenerated by rememberUpdatedState(onBookmarkCfiGenerated) + val currentOnScrollFinished by rememberUpdatedState(onScrollFinished) + LaunchedEffect(currentFontSize, currentLineHeight) { localWebViewRef?.evaluateJavascript( "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", @@ -499,6 +511,10 @@ fun ChapterWebView( consoleMessage?.let { val message = it.message() when { + message.startsWith("BookmarkDiagnosis") -> { + Timber.tag("BookmarkDiagnosis").d("JS -> ${message.substringAfter("BookmarkDiagnosis: ")}") + } + message.startsWith("CFI_DIAGNOSIS:") -> { Timber.d( "JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}" @@ -549,15 +565,17 @@ fun ChapterWebView( } addJavascriptInterface( CfiJsBridge( - onCfiReady = { cfi -> onCfiGenerated(cfi) }, - onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) } - ), "CfiBridge") - addJavascriptInterface(SnippetJsBridge { cfi, snippet -> - onSnippetForBookmarkReady( - cfi, - snippet - ) - }, "SnippetBridge") + onCfiReady = { cfi -> currentOnCfiGenerated(cfi) }, + onCfiForBookmarkReady = { cfi -> currentOnBookmarkCfiGenerated(cfi) }, + onScrollFinishedCallback = { success -> currentOnScrollFinished(success) } + ), "CfiBridge" + ) + + addJavascriptInterface( + SnippetJsBridge { cfi, snippet -> + currentOnSnippetForBookmarkReady(cfi, snippet) + }, "SnippetBridge" + ) addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge") addJavascriptInterface( AiJsBridge(ttsScope, onContentReadyForSummarization), diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 0935b0b..8e63a78 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -31,7 +31,6 @@ import android.graphics.Bitmap import android.media.AudioManager import android.net.Uri import android.os.Build -import timber.log.Timber import android.webkit.WebView import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -77,10 +76,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDrawerState @@ -103,6 +100,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester @@ -117,6 +115,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.core.content.edit import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle @@ -131,7 +130,6 @@ import com.aryan.reader.RenderMode import com.aryan.reader.SearchResult import com.aryan.reader.SummarizationResult import com.aryan.reader.SummaryCacheManager -import com.aryan.reader.SyncUpdateInfo import com.aryan.reader.countWords import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.epub.EpubBook @@ -165,14 +163,13 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.protobuf.ProtoBuf import org.json.JSONArray import org.json.JSONObject +import timber.log.Timber import java.io.File import kotlin.math.ceil import kotlin.math.floor import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt -import androidx.compose.ui.BiasAlignment -import androidx.core.content.edit private const val AUTO_SCROLL_LOCKED_KEY = "auto_scroll_locked" private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider" @@ -206,8 +203,6 @@ fun EpubReaderScreen( initialCfi: String?, initialBookmarksJson: String?, isProUser: Boolean, - pendingSyncUpdate: SyncUpdateInfo?, - onClearPendingSyncUpdate: () -> Unit, onNavigateBack: () -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit, @@ -230,8 +225,6 @@ fun EpubReaderScreen( onNavigateToPro = onNavigateToPro, coverImagePath = coverImagePath, onRenderModeChange = onRenderModeChange, - pendingSyncUpdate = pendingSyncUpdate, - onClearPendingSyncUpdate = onClearPendingSyncUpdate, customFonts = customFonts, onImportFont = onImportFont ) @@ -249,8 +242,6 @@ fun EpubReaderHost( initialCfi: String?, initialBookmarksJson: String?, isProUser: Boolean, - pendingSyncUpdate: SyncUpdateInfo?, - onClearPendingSyncUpdate: () -> Unit, onNavigateBack: () -> Unit, onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit, @@ -269,6 +260,7 @@ fun EpubReaderHost( val focusManager = LocalFocusManager.current val searchFocusRequester = remember { FocusRequester() } val containerFocusRequester = remember { FocusRequester() } + var isNavigatingToBookmark by remember { mutableStateOf(false) } var isPageSliderVisible by remember { mutableStateOf(false) } var sliderCurrentPage by remember { mutableFloatStateOf(0f) } @@ -559,62 +551,6 @@ fun EpubReaderHost( } } - LaunchedEffect(pendingSyncUpdate) { - if (pendingSyncUpdate != null) { - val locator = pendingSyncUpdate.locator - val message = if (locator != null) { - val chapterTitle = chapters.getOrNull(locator.chapterIndex)?.title ?: "another location" - "Newer reading position found in '$chapterTitle'. Sync now?" - } else { - "Bookmarks updated on another device. Sync now?" - } - - val result = withTimeoutOrNull(10_000L) { - snackbarHostState.showSnackbar( - message = message, - actionLabel = "Sync", - withDismissAction = true, - duration = SnackbarDuration.Indefinite - ) - } - - if (result == SnackbarResult.ActionPerformed) { - if (locator != null) { - when (currentRenderMode) { - RenderMode.VERTICAL_SCROLL -> { - val cfi = locatorConverter.getCfiFromLocator(epubBook.title, locator) - if (cfi != null) { - val targetChunk = locator.blockIndex / 20 - if (currentChapterIndex != locator.chapterIndex) { - chunkTargetOverride = targetChunk - currentChapterIndex = locator.chapterIndex - } else { - if (targetChunk >= loadedChunkCount) { - loadUpToChunkIndex = targetChunk - } - } - cfiToLoad = cfi - } else { - Timber.w("Could not get CFI from locator for sync.") - } - } - RenderMode.PAGINATED -> { - (paginator as? BookPaginator)?.findPageForLocator(locator)?.let { page -> - scope.launch { - paginatedPagerState.scrollToPage(page) - } - } - } - } - } - pendingSyncUpdate.bookmarksJson?.let { newBookmarksJson -> - bookmarks = loadBookmarks(context, epubBook.title, chapters, newBookmarksJson) - } - } - onClearPendingSyncUpdate() - } - } - LaunchedEffect(skipChapterRequest) { if (skipChapterRequest) { skipChapterRequest = false @@ -1261,32 +1197,95 @@ fun EpubReaderHost( when (currentRenderMode) { RenderMode.VERTICAL_SCROLL -> { + Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}") cfiToLoad = bookmark.cfi - val locator = locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi) - val targetChunk = locator?.let { it.blockIndex / 20 } + + // FIX: Try to extract chunk index directly from CFI for Vertical Mode + // Vertical Mode CFIs are relative to content-container, so the first number + // usually represents the chunk (2->Chunk0, 4->Chunk1, 6->Chunk2...) + val directChunkIndex = try { + val parts = bookmark.cfi.split('/').mapNotNull { it.toIntOrNull() } + if (parts.isNotEmpty()) { + val firstIndex = parts[0] + // Standard EPUB CFI: indices are 1-based steps (2, 4, 6...) + (firstIndex - 2) / 2 + } else null + } catch (e: Exception) { + null + } + + val locator = if (directChunkIndex == null) { + locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi) + } else { + null + } + + val targetChunk = directChunkIndex ?: locator?.let { it.blockIndex / 20 } if (bookmark.chapterIndex != currentChapterIndex) { - if (targetChunk != null) { - chunkTargetOverride = targetChunk + chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) { + targetChunk } else { - chunkTargetOverride = 0 - Timber.w("Could not get locator for bookmark CFI, will navigate to start of chapter.") + 0 } currentChapterIndex = bookmark.chapterIndex - } else { - if (targetChunk != null) { + } + else { + if (targetChunk != null && targetChunk >= 0) { + isNavigatingToBookmark = true + + // FIX: Ensure we don't reload if we already have it, + // but do ensure the WebView has the content injected. if (targetChunk >= loadedChunkCount) { + Timber.tag("BookmarkDiagnosis").d("Manual Chunk Injection: Loading from $loadedChunkCount to $targetChunk") + + val chunksToInject = (loadedChunkCount..targetChunk) + chunksToInject.forEach { idx -> + val content = chapterChunks.getOrNull(idx) + if (content != null) { + val escaped = escapeJsString(content) + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($idx, '$escaped');", + null + ) + } + } loadUpToChunkIndex = targetChunk + loadedChunkCount = max(loadedChunkCount, targetChunk + 1) } else { - webViewRefForTts?.evaluateJavascript("javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", null) + // Even if loadedChunkCount is high enough in Kotlin state, + // ensure the specific chunk for the bookmark is actually in the DOM. + // (Sometimes rapid jumps might leave gaps if logic was loose) + val content = chapterChunks.getOrNull(targetChunk) + if (content != null) { + val escaped = escapeJsString(content) + webViewRefForTts?.evaluateJavascript( + "javascript:window.virtualization.appendChunk($targetChunk, '$escaped');", + null + ) + } + } + + webViewRefForTts?.evaluateJavascript( + "javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", + null + ) + + scope.launch { + delay(3000) + if (isNavigatingToBookmark) { + isNavigatingToBookmark = false + } } } else { - Timber.w("Could not get locator for bookmark CFI in current chapter, loading all chunks as fallback.") - loadUpToChunkIndex = if (chapterChunks.isNotEmpty()) chapterChunks.size - 1 else 0 + // Fallback if we couldn't determine chunk + webViewRefForTts?.evaluateJavascript( + "javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", + null + ) } } } - RenderMode.PAGINATED -> { Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'") val locator = locatorConverter.getLocatorFromCfi( @@ -1588,7 +1587,7 @@ fun EpubReaderHost( "ControlFlowWithEmptyBody" ) ChapterWebView( - key = "$chapterKeyForWebView-$loadUpToChunkIndex", + key = "$chapterKeyForWebView", chapterTitle = chapterToRender.title, isDarkTheme = isDarkTheme, initialScrollTarget = initialScrollTargetForChapter, @@ -1795,6 +1794,10 @@ fun EpubReaderHost( null ) }, + onScrollFinished = { success -> + Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success") + isNavigatingToBookmark = false + }, ttsScope = scope, onTtsTextReady = { jsonString -> scope.launch { @@ -2959,6 +2962,26 @@ fun EpubReaderHost( isTtsSessionActive = isTtsSessionActive ) + if (isNavigatingToBookmark) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f)) + .clickable(enabled = true) {}, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Navigating to bookmark...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + } + } + } + if (showPermissionRationaleDialog) { AlertDialog( onDismissRequest = { showPermissionRationaleDialog = false }, diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt b/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt index e0a2759..70cad84 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt @@ -48,7 +48,6 @@ private fun Color.luminance(): Float { return (0.299f * red + 0.587f * green + 0.114f * blue) } -@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) object CssParser { private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL) private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex() diff --git a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt index 43c1464..d53f07c 100644 --- a/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt +++ b/app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt @@ -20,9 +20,7 @@ package com.aryan.reader.paginatedreader import android.content.Context -import android.os.Build import timber.log.Timber -import androidx.annotation.RequiresApi import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density @@ -51,7 +49,6 @@ class LocatorConverter( private val proto: ProtoBuf, private val context: Context ) { - @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List? = withContext(Dispatchers.IO) { try { val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null @@ -114,12 +111,7 @@ class LocatorConverter( proto.decodeFromByteArray>(processedChapter.contentBlocksProto) } else { Timber.w("getLocatorFromCfi: Chapter $chapterIndex not in DB. Triggering on-demand processing.") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - processAndCacheChapter(book, chapterIndex) - } else { - Timber.e("On-demand processing requires API 34+, cannot proceed.") - null - } + processAndCacheChapter(book, chapterIndex) } if (allBlocks == null) { @@ -238,9 +230,7 @@ class LocatorConverter( val allBlocks = if (processedChapter != null) { proto.decodeFromByteArray>(processedChapter.contentBlocksProto) } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - processAndCacheChapter(book, locator.chapterIndex) - } else null + processAndCacheChapter(book, locator.chapterIndex) } ?: return@withContext null var offset = 0 diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 479ecb5..e816249 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -565,8 +565,6 @@ fun PdfViewerScreen( initialPage: Int?, initialBookmarksJson: String?, isProUser: Boolean, - pendingSyncUpdate: SyncUpdateInfo?, - onClearPendingSyncUpdate: () -> Unit, onNavigateBack: () -> Unit, onSavePosition: (page: Int, totalPages: Int) -> Unit, onBookmarksChanged: (bookmarksJson: String) -> Unit, @@ -1553,39 +1551,6 @@ fun PdfViewerScreen( var showRenameBookmarkDialog by remember { mutableStateOf(null) } - LaunchedEffect(pendingSyncUpdate) { - if (pendingSyncUpdate != null) { - val newPage = pendingSyncUpdate.page - val message = if (newPage != null) { - "Newer reading position found. Sync to page ${newPage + 1}?" - } else { - "Bookmarks updated on another device. Sync now?" - } - - val result = withTimeoutOrNull(10_000L) { - snackbarHostState.showSnackbar( - message = message, - actionLabel = "Sync", - withDismissAction = true, - duration = SnackbarDuration.Indefinite - ) - } - - if (result == SnackbarResult.ActionPerformed) { - if (newPage != null) { - when (displayMode) { - DisplayMode.PAGINATION -> pagerState.scrollToPage(newPage) - DisplayMode.VERTICAL_SCROLL -> verticalReaderState.scrollToPage(newPage) - } - } - pendingSyncUpdate.bookmarksJson?.let { newBookmarksJson -> - bookmarks = loadPdfBookmarksFromJson(newBookmarksJson) - } - } - onClearPendingSyncUpdate() - } - } - var isOcrModelDownloading by remember { mutableStateOf(false) } LaunchedEffect(isOcrModelDownloading) { diff --git a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt index 6a02adc..4b60807 100644 --- a/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt +++ b/app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt @@ -38,9 +38,9 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlinx.coroutines.delay -private const val START_TIMEOUT_FAST_MS = 750L -private const val START_TIMEOUT_RETRY_MS = 2500L -private const val PROCESS_TIMEOUT_MS = 4000L +private const val START_TIMEOUT_FAST_MS = 3000L +private const val START_TIMEOUT_RETRY_MS = 4000L +private const val PROCESS_TIMEOUT_MS = 15000L private const val MAX_RETRY_ATTEMPTS = 3 class BaseTtsSynthesizer(private val context: Context) { diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index a494f37..a65daea 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -180,9 +180,11 @@ class TtsPlaybackManager( val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name) val playbackSource = args.getString(KEY_PLAYBACK_SOURCE) val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD } + val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) { chunks.mapIndexed { index, text -> - TtsChunk(text, cfis[index], offsets[index]) + val safeOffset = offsets.getOrNull(index) ?: -1 + TtsChunk(text, cfis[index], safeOffset) } } else { chunks.map { TtsChunk(it, "", -1) }