Folder sync patch (#22)
* Refactored folder synchronization logic and improved migration handling - Updated `MainViewModel` to skip synchronization until folder migration is completed and added metadata syncing when opening books. - Modified folder migration to detach existing folder books (converting them to standard local files) before starting a fresh scan. - Added `detachAllFolderBooks` to `RecentFileDao` and `RecentFilesRepository` to facilitate the migration process. - Updated `MetadataExtractionWorker` to sync local metadata to the folder immediately after extraction. - Simplified `FolderSyncWorker` by removing legacy migration lookups and streamlining the reconciliation of local and remote metadata. - Updated `FolderMigrationDialog` UI text to reflect that books are now read directly from folders without duplication. - Fixed various indentation and formatting issues across several files. * Removed the `.episteme` sync directory and moved book metadata files directly to the folder root as hidden files.
This commit is contained in:
parent
0885b6949e
commit
7d26e10c07
5 changed files with 195 additions and 257 deletions
|
|
@ -21,7 +21,6 @@
|
||||||
package com.aryan.reader
|
package com.aryan.reader
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
|
@ -32,9 +31,6 @@ import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.aryan.reader.data.RecentFileItem
|
import com.aryan.reader.data.RecentFileItem
|
||||||
import com.aryan.reader.data.RecentFilesRepository
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
@ -48,10 +44,6 @@ class FolderSyncWorker(
|
||||||
) : CoroutineWorker(appContext, workerParams) {
|
) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
private val recentFilesRepository = RecentFilesRepository(appContext)
|
private val recentFilesRepository = RecentFilesRepository(appContext)
|
||||||
private val epubParser = EpubParser(appContext)
|
|
||||||
private val mobiParser = MobiParser(appContext)
|
|
||||||
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
|
|
||||||
private val bookImporter = BookImporter(appContext)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val WORK_NAME = "FolderSyncWorker"
|
const val WORK_NAME = "FolderSyncWorker"
|
||||||
|
|
@ -62,6 +54,13 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
||||||
|
// Check if folder is still linked before starting
|
||||||
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) {
|
||||||
|
Timber.tag("FolderSync").w("Worker: Folder unlinked. Aborting work.")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...")
|
Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...")
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
|
|
@ -80,6 +79,7 @@ class FolderSyncWorker(
|
||||||
val folderUri = folderUriString.toUri()
|
val folderUri = folderUriString.toUri()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Permission checks ...
|
||||||
try {
|
try {
|
||||||
appContext.contentResolver.takePersistableUriPermission(
|
appContext.contentResolver.takePersistableUriPermission(
|
||||||
folderUri,
|
folderUri,
|
||||||
|
|
@ -94,21 +94,55 @@ class FolderSyncWorker(
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PHASE 1: IMPORT METADATA (Always run this to catch updates from other devices)
|
||||||
|
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
|
||||||
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
|
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
|
||||||
|
|
||||||
|
// Apply updates to local DB
|
||||||
|
folderMetadataMap.forEach { (bookId, remoteMeta) ->
|
||||||
|
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
||||||
|
|
||||||
|
if (existingItem != null) {
|
||||||
|
// Update local if remote is newer
|
||||||
|
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
|
||||||
|
Timber.tag("FolderSync").d("Applying remote update for $bookId (Progress: ${remoteMeta.progressPercentage}%)")
|
||||||
|
val itemToUpdate = 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,
|
||||||
|
// Crucial: If remote says it's recent, we make it recent locally
|
||||||
|
isRecent = remoteMeta.isRecent || existingItem.isRecent,
|
||||||
|
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
|
||||||
|
)
|
||||||
|
recentFilesRepository.addRecentFile(itemToUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We do NOT create new items from JSON alone. We wait for Phase 2 to find the file.
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHASE 2: SCAN PHYSICAL FILES (Only if !metadataOnly)
|
||||||
if (!metadataOnly) {
|
if (!metadataOnly) {
|
||||||
|
Timber.tag("FolderSync").d("Phase 2: Scanning physical files...")
|
||||||
val currentDiskFiles = mutableListOf<DocumentFile>()
|
val currentDiskFiles = mutableListOf<DocumentFile>()
|
||||||
val fileQueue = ArrayDeque<DocumentFile>()
|
val fileQueue = ArrayDeque<DocumentFile>()
|
||||||
documentTree.listFiles().let { fileQueue.addAll(it) }
|
documentTree.listFiles().let { fileQueue.addAll(it) }
|
||||||
|
|
||||||
while (fileQueue.isNotEmpty()) {
|
while (fileQueue.isNotEmpty()) {
|
||||||
|
if (isStopped) break
|
||||||
|
|
||||||
val file = fileQueue.removeAt(0)
|
val file = fileQueue.removeAt(0)
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
if (file.name == ".episteme") continue
|
// REMOVED: if (file.name == ".episteme") continue
|
||||||
file.listFiles().let { fileQueue.addAll(it) }
|
file.listFiles().let { fileQueue.addAll(it) }
|
||||||
} else if (file.isFile) {
|
} else if (file.isFile) {
|
||||||
val name = file.name ?: ""
|
val name = file.name ?: ""
|
||||||
if (isValidExtension(name)) {
|
// UPDATED: Ensure we ignore .json files and hidden files during book scan
|
||||||
|
if (isValidExtension(name) && !name.endsWith(".json") && !name.startsWith(".")) {
|
||||||
currentDiskFiles.add(file)
|
currentDiskFiles.add(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,12 +151,15 @@ class FolderSyncWorker(
|
||||||
val foundBookIds = mutableSetOf<String>()
|
val foundBookIds = mutableSetOf<String>()
|
||||||
|
|
||||||
for (file in currentDiskFiles) {
|
for (file in currentDiskFiles) {
|
||||||
|
if (isStopped) break
|
||||||
|
|
||||||
val stableId = "local_${file.name}_${file.length()}"
|
val stableId = "local_${file.name}_${file.length()}"
|
||||||
foundBookIds.add(stableId)
|
foundBookIds.add(stableId)
|
||||||
|
|
||||||
val existingItem = recentFilesRepository.getFileByBookId(stableId)
|
val existingItem = recentFilesRepository.getFileByBookId(stableId)
|
||||||
|
|
||||||
if (existingItem == null) {
|
if (existingItem == null) {
|
||||||
|
// NEW FILE DISCOVERED
|
||||||
val remoteMeta = folderMetadataMap[stableId]
|
val remoteMeta = folderMetadataMap[stableId]
|
||||||
val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB
|
val type = getFileType(file.name ?: "", file.type) ?: FileType.EPUB
|
||||||
val placeholderTitle = file.name ?: "Unknown"
|
val placeholderTitle = file.name ?: "Unknown"
|
||||||
|
|
@ -132,6 +169,7 @@ class FolderSyncWorker(
|
||||||
uriString = file.uri.toString(),
|
uriString = file.uri.toString(),
|
||||||
type = type,
|
type = type,
|
||||||
displayName = file.name ?: "Unknown",
|
displayName = file.name ?: "Unknown",
|
||||||
|
// Use remote timestamp if available, else NOW
|
||||||
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
timestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
||||||
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
lastModifiedTimestamp = remoteMeta?.lastModifiedTimestamp ?: System.currentTimeMillis(),
|
||||||
coverImagePath = null,
|
coverImagePath = null,
|
||||||
|
|
@ -139,7 +177,8 @@ class FolderSyncWorker(
|
||||||
author = remoteMeta?.author,
|
author = remoteMeta?.author,
|
||||||
isAvailable = true,
|
isAvailable = true,
|
||||||
isDeleted = false,
|
isDeleted = false,
|
||||||
isRecent = false,
|
// If remote says recent, mark it. If new and no remote data, it is NOT recent.
|
||||||
|
isRecent = remoteMeta?.isRecent ?: false,
|
||||||
sourceFolderUri = folderUriString,
|
sourceFolderUri = folderUriString,
|
||||||
lastChapterIndex = remoteMeta?.lastChapterIndex,
|
lastChapterIndex = remoteMeta?.lastChapterIndex,
|
||||||
lastPage = remoteMeta?.lastPage,
|
lastPage = remoteMeta?.lastPage,
|
||||||
|
|
@ -149,37 +188,22 @@ class FolderSyncWorker(
|
||||||
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
|
locatorBlockIndex = remoteMeta?.locatorBlockIndex,
|
||||||
locatorCharOffset = remoteMeta?.locatorCharOffset
|
locatorCharOffset = remoteMeta?.locatorCharOffset
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Insert. Note: We do NOT call syncLocalMetadataToFolder here.
|
||||||
|
// It is "Clean" until the user opens it.
|
||||||
recentFilesRepository.addRecentFile(newItem)
|
recentFilesRepository.addRecentFile(newItem)
|
||||||
} else {
|
} else {
|
||||||
var itemToUpdate = existingItem
|
// EXISTING FILE - Just ensure it is marked available
|
||||||
|
if (existingItem.isDeleted || !existingItem.isAvailable) {
|
||||||
if (existingItem.isDeleted) {
|
val revived = existingItem.copy(isDeleted = false, isAvailable = true)
|
||||||
itemToUpdate = itemToUpdate.copy(isDeleted = false, isAvailable = true)
|
recentFilesRepository.addRecentFile(revived)
|
||||||
}
|
|
||||||
|
|
||||||
val remoteMeta = folderMetadataMap[stableId]
|
|
||||||
if (remoteMeta != null && remoteMeta.lastModifiedTimestamp > itemToUpdate.lastModifiedTimestamp) {
|
|
||||||
itemToUpdate = itemToUpdate.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(itemToUpdate)
|
|
||||||
|
|
||||||
if (remoteMeta == null || itemToUpdate.lastModifiedTimestamp > remoteMeta.lastModifiedTimestamp) {
|
|
||||||
recentFilesRepository.syncLocalMetadataToFolder(stableId)
|
|
||||||
}
|
}
|
||||||
|
// Metadata updates were already handled in Phase 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup removed files
|
||||||
|
if (!isStopped) {
|
||||||
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
|
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
|
||||||
|
|
||||||
|
|
@ -188,9 +212,11 @@ class FolderSyncWorker(
|
||||||
recentFilesRepository.deleteFilePermanently(idsToRemove)
|
recentFilesRepository.deleteFilePermanently(idsToRemove)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
|
prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
|
||||||
|
|
||||||
|
if (!isStopped) {
|
||||||
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
|
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
|
||||||
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
|
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
|
||||||
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
WorkManager.getInstance(appContext).enqueueUniqueWork(
|
||||||
|
|
@ -198,6 +224,7 @@ class FolderSyncWorker(
|
||||||
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||||
metaRequest
|
metaRequest
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return Result.success()
|
return Result.success()
|
||||||
|
|
||||||
|
|
@ -207,53 +234,6 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class ExtractedInfo(
|
|
||||||
val title: String? = null,
|
|
||||||
val author: String? = null,
|
|
||||||
val coverPath: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
private suspend fun extractFileInfo(uri: Uri, type: FileType, displayName: String): ExtractedInfo {
|
|
||||||
var coverPath: String? = null
|
|
||||||
var title: String? = null
|
|
||||||
var author: String? = null
|
|
||||||
|
|
||||||
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() }
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to extract info for file: $displayName")
|
|
||||||
}
|
|
||||||
return ExtractedInfo(title, author, coverPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isValidExtension(name: String): Boolean {
|
private fun isValidExtension(name: String): Boolean {
|
||||||
return name.endsWith(".pdf", true) ||
|
return name.endsWith(".pdf", true) ||
|
||||||
name.endsWith(".epub", true) ||
|
name.endsWith(".epub", true) ||
|
||||||
|
|
|
||||||
|
|
@ -1373,9 +1373,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
fun disconnectSyncedFolder() {
|
fun disconnectSyncedFolder() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val folderUriString = _internalState.value.syncedFolderUri
|
val folderUriString = _internalState.value.syncedFolderUri
|
||||||
|
|
||||||
|
Timber.tag("FolderSync").d("Cancelling all folder sync workers...")
|
||||||
|
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||||
|
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
|
||||||
|
WorkManager.getInstance(appContext).cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
|
||||||
|
|
||||||
|
prefs.edit {
|
||||||
|
remove(KEY_SYNCED_FOLDER_URI)
|
||||||
|
remove(KEY_LAST_FOLDER_SCAN_TIME)
|
||||||
|
}
|
||||||
|
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) }
|
||||||
|
|
||||||
if (folderUriString != null) {
|
if (folderUriString != null) {
|
||||||
Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.")
|
Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.")
|
||||||
recentFilesRepository.deleteFilesBySourceFolder(folderUriString) // New DAO method call
|
recentFilesRepository.deleteFilesBySourceFolder(folderUriString)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val uri = folderUriString.toUri()
|
val uri = folderUriString.toUri()
|
||||||
|
|
@ -1387,14 +1399,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
Timber.e(e, "Failed to release permission")
|
Timber.e(e, "Failed to release permission")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit {
|
|
||||||
remove(KEY_SYNCED_FOLDER_URI)
|
|
||||||
remove(KEY_LAST_FOLDER_SCAN_TIME)
|
|
||||||
}
|
|
||||||
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) }
|
|
||||||
|
|
||||||
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3100,14 +3104,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
try {
|
try {
|
||||||
val rootUri = item.sourceFolderUri.toUri()
|
val rootUri = item.sourceFolderUri.toUri()
|
||||||
val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri)
|
val rootDoc = DocumentFile.fromTreeUri(appContext, rootUri)
|
||||||
val syncDir = rootDoc?.findFile("episteme") ?: rootDoc?.findFile(".episteme")
|
|
||||||
|
|
||||||
if (syncDir != null) {
|
if (rootDoc != null) {
|
||||||
// Try hidden first, then legacy
|
val hiddenMeta = rootDoc.findFile(".${item.bookId}.json")
|
||||||
val metaFile = syncDir.findFile(".${item.bookId}.json")
|
val legacyVisibleMeta = rootDoc.findFile("${item.bookId}.json")
|
||||||
?: syncDir.findFile("${item.bookId}.json")
|
|
||||||
|
|
||||||
metaFile?.delete()
|
hiddenMeta?.delete()
|
||||||
|
legacyVisibleMeta?.delete()
|
||||||
|
|
||||||
|
Timber.tag("FolderSync").d("Deleted metadata for ${item.bookId} from root.")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Error deleting metadata file for ${item.bookId}")
|
Timber.e(e, "Error deleting metadata file for ${item.bookId}")
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,13 @@ class MetadataExtractionWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
|
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) {
|
||||||
|
Timber.tag("MetadataWorker").w("Folder disconnected. Stopping extraction worker.")
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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()
|
val filesToProcess = recentFilesRepository.getFolderBooksWithoutCovers()
|
||||||
|
|
||||||
if (filesToProcess.isEmpty()) {
|
if (filesToProcess.isEmpty()) {
|
||||||
|
|
@ -41,6 +46,8 @@ class MetadataExtractionWorker(
|
||||||
filesToProcess.forEach { item ->
|
filesToProcess.forEach { item ->
|
||||||
if (isStopped) return@forEach
|
if (isStopped) return@forEach
|
||||||
|
|
||||||
|
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) return@forEach
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val uri = item.uriString?.toUri() ?: return@forEach
|
val uri = item.uriString?.toUri() ?: return@forEach
|
||||||
val type = item.type
|
val type = item.type
|
||||||
|
|
@ -49,7 +56,6 @@ class MetadataExtractionWorker(
|
||||||
var title: String? = null
|
var title: String? = null
|
||||||
var author: String? = null
|
var author: String? = null
|
||||||
|
|
||||||
// We open the stream briefly to extract metadata
|
|
||||||
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
when (type) {
|
when (type) {
|
||||||
FileType.EPUB -> {
|
FileType.EPUB -> {
|
||||||
|
|
@ -58,35 +64,25 @@ class MetadataExtractionWorker(
|
||||||
originalBookNameHint = item.displayName,
|
originalBookNameHint = item.displayName,
|
||||||
parseContent = false
|
parseContent = false
|
||||||
)
|
)
|
||||||
book.let {
|
title = book.title.takeIf { it.isNotBlank() }
|
||||||
title = it.title.takeIf { t -> t.isNotBlank() }
|
author = book.author.takeIf { it.isNotBlank() }
|
||||||
author = it.author.takeIf { a -> a.isNotBlank() }
|
book.coverImage?.let { coverPath = recentFilesRepository.saveCoverToCache(it, uri) }
|
||||||
it.coverImage?.let { img ->
|
|
||||||
coverPath = recentFilesRepository.saveCoverToCache(img, uri)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
FileType.MOBI -> {
|
FileType.MOBI -> {
|
||||||
val book = mobiParser.createMobiBook(
|
val book = mobiParser.createMobiBook(inputStream, item.displayName)
|
||||||
inputStream = inputStream,
|
|
||||||
originalBookNameHint = item.displayName
|
|
||||||
)
|
|
||||||
book?.let {
|
book?.let {
|
||||||
title = it.title.takeIf { t -> t.isNotBlank() }
|
title = it.title.takeIf { t -> t.isNotBlank() }
|
||||||
author = it.author.takeIf { a -> a.isNotBlank() }
|
author = it.author.takeIf { a -> a.isNotBlank() }
|
||||||
it.coverImage?.let { img ->
|
it.coverImage?.let { img -> coverPath = recentFilesRepository.saveCoverToCache(img, uri) }
|
||||||
coverPath = recentFilesRepository.saveCoverToCache(img, uri)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FileType.PDF -> {
|
FileType.PDF -> {
|
||||||
// PDF cover generation is heavy, but necessary
|
|
||||||
pdfCoverGenerator.generateCover(uri)?.let {
|
pdfCoverGenerator.generateCover(uri)?.let {
|
||||||
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
|
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
|
||||||
}
|
}
|
||||||
title = item.displayName.substringBeforeLast(".") // Clean filename
|
title = item.displayName.substringBeforeLast(".")
|
||||||
}
|
}
|
||||||
else -> { /* Text/MD files usually don't have covers */ }
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,14 +93,8 @@ class MetadataExtractionWorker(
|
||||||
author = author ?: item.author
|
author = author ?: item.author
|
||||||
)
|
)
|
||||||
recentFilesRepository.addRecentFile(updatedItem)
|
recentFilesRepository.addRecentFile(updatedItem)
|
||||||
Timber.tag("MetadataWorker").d("Updated metadata for: ${item.displayName}")
|
Timber.tag("MetadataWorker").d("Updated local metadata for: ${item.displayName}")
|
||||||
|
|
||||||
try {
|
|
||||||
recentFilesRepository.syncLocalMetadataToFolder(updatedItem.bookId)
|
|
||||||
Timber.tag("MetadataWorker").d("Created/Updated JSON for: ${item.displayName}")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
Timber.tag("MetadataWorker").w("Failed to save JSON during extraction for ${item.displayName}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ package com.aryan.reader.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
object LocalSyncUtils {
|
object LocalSyncUtils {
|
||||||
private const val SYNC_DIR_NAME = "episteme"
|
// REMOVED: private const val SYNC_DIR_NAME = "episteme"
|
||||||
private const val TAG = "FolderSync"
|
private const val TAG = "FolderSync"
|
||||||
|
|
||||||
suspend fun saveMetadataToFolder(
|
suspend fun saveMetadataToFolder(
|
||||||
|
|
@ -20,62 +22,41 @@ object LocalSyncUtils {
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
|
||||||
|
|
||||||
val syncDir = getOrCreateSyncDir(rootTree)
|
// CHANGED: Primary filename now starts with a dot
|
||||||
|
val syncFileName = ".${metadata.bookId}.json"
|
||||||
|
val legacyVisibleName = "${metadata.bookId}.json"
|
||||||
|
|
||||||
if (syncDir == null) {
|
val existingHidden = rootTree.findFile(syncFileName)
|
||||||
Timber.tag(TAG).e("Could not create/find $SYNC_DIR_NAME directory in $sourceFolderUri")
|
val existingVisible = rootTree.findFile(legacyVisibleName)
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure .nomedia exists to prevent gallery clutter
|
val fileToCheck = existingHidden ?: existingVisible
|
||||||
ensureNoMedia(syncDir)
|
|
||||||
|
|
||||||
// Use hidden filename to avoid "Recents" clutter
|
if (fileToCheck != null && fileToCheck.exists()) {
|
||||||
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 {
|
try {
|
||||||
val existingContent = context.contentResolver.openInputStream(existingFile.uri)?.use { input ->
|
val existingContent = context.contentResolver.openInputStream(fileToCheck.uri)?.use { input ->
|
||||||
input.bufferedReader().use { it.readText() }
|
input.bufferedReader().use { it.readText() }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingContent != null) {
|
if (existingContent != null) {
|
||||||
val existingMeta = FolderBookMetadata.fromJsonString(existingContent)
|
val existingMeta = FolderBookMetadata.fromJsonString(existingContent)
|
||||||
val diff = existingMeta.lastModifiedTimestamp - metadata.lastModifiedTimestamp
|
|
||||||
|
|
||||||
// Clobber Protection
|
|
||||||
if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
||||||
Timber.tag(TAG).w("ClobberCheck: ABORTING save for ${metadata.bookId}. Folder has newer data.")
|
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data.")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (_: 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
|
var targetFile = existingHidden
|
||||||
try {
|
|
||||||
existingFile.delete()
|
if (targetFile == null) {
|
||||||
} catch (e: Exception) {
|
// Migrate: If a visible one existed, we'll replace it with hidden
|
||||||
Timber.tag(TAG).w("Failed to delete existing metadata file: ${e.message}")
|
if (existingVisible != null && existingVisible.exists()) {
|
||||||
|
try { existingVisible.delete() } catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
targetFile = rootTree.createFile("application/json", syncFileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we had a legacy file that wasn't the 'existingFile' (edge case), delete it too
|
if (targetFile == null) {
|
||||||
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}")
|
Timber.tag(TAG).e("Could not create metadata file for ${metadata.bookId}")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
@ -83,13 +64,32 @@ object LocalSyncUtils {
|
||||||
val jsonString = metadata.toJsonString()
|
val jsonString = metadata.toJsonString()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
context.contentResolver.openOutputStream(newFile.uri)?.use { output ->
|
context.contentResolver.openFileDescriptor(targetFile.uri, "rwt")?.use { pfd ->
|
||||||
output.write(jsonString.toByteArray())
|
java.io.FileOutputStream(pfd.fileDescriptor).use { fos ->
|
||||||
|
fos.write(jsonString.toByteArray())
|
||||||
|
fos.flush()
|
||||||
|
try {
|
||||||
|
pfd.fileDescriptor.sync()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Timber.tag(TAG).w("FileDescriptor sync not supported on this device/filesystem")
|
||||||
}
|
}
|
||||||
Timber.tag(TAG).d("Saved metadata for ${metadata.bookId} (Hidden)")
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val absolutePath = getPathFromUri(context, targetFile.uri)
|
||||||
|
if (absolutePath != null) {
|
||||||
|
android.media.MediaScannerConnection.scanFile(
|
||||||
|
context,
|
||||||
|
arrayOf(absolutePath),
|
||||||
|
arrayOf("application/json"),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag(TAG).d("Saved hidden metadata for ${metadata.bookId}")
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag(TAG).e(e, "Failed to write content to metadata file for ${metadata.bookId}")
|
Timber.tag(TAG).e(e, "Failed to write metadata for ${metadata.bookId}")
|
||||||
try { newFile.delete() } catch (_: Exception) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -97,29 +97,30 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getBookMetadata(
|
/**
|
||||||
context: Context,
|
* Helper to attempt to resolve a SAF URI to an absolute filesystem path.
|
||||||
sourceFolderUri: Uri,
|
* This is required because MediaScannerConnection does not accept content:// URIs.
|
||||||
bookId: String
|
*/
|
||||||
): FolderBookMetadata? = withContext(Dispatchers.IO) {
|
private fun getPathFromUri(context: Context, uri: Uri): String? {
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext null
|
if (DocumentsContract.isDocumentUri(context, uri) && isExternalStorageDocument(uri)) {
|
||||||
val syncDir = findSyncDir(rootTree) ?: return@withContext null
|
val docId = DocumentsContract.getDocumentId(uri)
|
||||||
|
val split = docId.split(":")
|
||||||
|
val type = split[0]
|
||||||
|
|
||||||
// Find all related files: hidden, legacy, and conflicts
|
if ("primary".equals(type, ignoreCase = true)) {
|
||||||
val relatedFiles = syncDir.listFiles().filter { file ->
|
@Suppress("DEPRECATION")
|
||||||
val name = file.name ?: ""
|
return Environment.getExternalStorageDirectory().toString() + "/" + split[1]
|
||||||
// Match: .bookId.json, bookId.json, or containing .sync-conflict
|
}
|
||||||
(name.contains(bookId)) && (name.endsWith(".json") || name.contains(".sync-conflict"))
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Timber.tag(TAG).w("Could not resolve absolute path for URI: $uri")
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (relatedFiles.isEmpty()) return@withContext null
|
private fun isExternalStorageDocument(uri: Uri): Boolean {
|
||||||
|
return "com.android.externalstorage.documents" == uri.authority
|
||||||
return@withContext resolveAndCleanConflicts(context, relatedFiles, bookId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.tag(TAG).e(e, "Error resolving book metadata for $bookId")
|
|
||||||
}
|
|
||||||
return@withContext null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -142,9 +143,8 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
if (jsonString != null) {
|
if (jsonString != null) {
|
||||||
val meta = FolderBookMetadata.fromJsonString(jsonString)
|
val meta = FolderBookMetadata.fromJsonString(jsonString)
|
||||||
// Ensure this file actually belongs to the book (defensive check against partial name matches)
|
|
||||||
if (meta.bookId == bookId) {
|
if (meta.bookId == bookId) {
|
||||||
if (bestMeta == null || meta.lastModifiedTimestamp > bestMeta!!.lastModifiedTimestamp) {
|
if (bestMeta == null || meta.lastModifiedTimestamp > bestMeta.lastModifiedTimestamp) {
|
||||||
bestMeta = meta
|
bestMeta = meta
|
||||||
bestFile = file
|
bestFile = file
|
||||||
}
|
}
|
||||||
|
|
@ -157,23 +157,19 @@ object LocalSyncUtils {
|
||||||
|
|
||||||
// 2. Clean up losers
|
// 2. Clean up losers
|
||||||
if (bestMeta != null && bestFile != null) {
|
if (bestMeta != null && bestFile != null) {
|
||||||
val filesToDelete = files.filter { it.uri != bestFile!!.uri }
|
val filesToDelete = files.filter { it.uri != bestFile.uri }
|
||||||
|
|
||||||
if (filesToDelete.isNotEmpty()) {
|
if (filesToDelete.isNotEmpty()) {
|
||||||
Timber.tag(TAG).i("Resolving conflicts for $bookId. Winner: ${bestFile!!.name}. Deleting ${filesToDelete.size} obsolete files.")
|
Timber.tag(TAG).i("Resolving conflicts for $bookId. Winner: ${bestFile.name}. Deleting ${filesToDelete.size} obsolete files.")
|
||||||
filesToDelete.forEach {
|
filesToDelete.forEach {
|
||||||
try { it.delete() } catch(_: Exception) {}
|
try { it.delete() } catch(_: Exception) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Migrate Legacy to Hidden if needed
|
// 3. Migrate Legacy to Hidden if needed
|
||||||
val winnerName = bestFile!!.name ?: ""
|
val winnerName = bestFile.name ?: ""
|
||||||
if (!winnerName.startsWith(".")) {
|
if (!winnerName.startsWith(".")) {
|
||||||
Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName")
|
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.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,30 +184,16 @@ object LocalSyncUtils {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
|
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext finalResults
|
||||||
val syncDir = findSyncDir(rootTree) ?: return@withContext finalResults
|
|
||||||
|
|
||||||
// Ensure .nomedia exists while scanning
|
// CHANGED: Scanning rootTree directly
|
||||||
ensureNoMedia(syncDir)
|
val allFiles = rootTree.listFiles()
|
||||||
|
|
||||||
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
|
val groupedFiles = allFiles
|
||||||
.filter { it.name?.endsWith(".json") == true || it.name?.contains(".sync-conflict") == true }
|
.filter { it.name?.endsWith(".json") == true || it.name?.contains(".sync-conflict") == true }
|
||||||
.groupBy { file ->
|
.groupBy { file ->
|
||||||
var name = file.name ?: ""
|
var name = file.name ?: ""
|
||||||
|
|
||||||
// Remove leading dot
|
|
||||||
if (name.startsWith(".")) name = name.substring(1)
|
if (name.startsWith(".")) name = name.substring(1)
|
||||||
|
|
||||||
// Remove conflict suffix
|
|
||||||
name = name.substringBefore(".sync-conflict")
|
name = name.substringBefore(".sync-conflict")
|
||||||
|
|
||||||
// Remove extension
|
|
||||||
name.substringBefore(".json")
|
name.substringBefore(".json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,39 +204,11 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records.")
|
Timber.tag(TAG).d("getAllFolderMetadata: Consolidated ${groupedFiles.size} book records from root.")
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag(TAG).e(e, "Error scanning .episteme folder")
|
Timber.tag(TAG).e(e, "Error scanning root folder for metadata")
|
||||||
}
|
}
|
||||||
return@withContext finalResults
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +120,15 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
val folderUriString = entity.sourceFolderUri
|
val folderUriString = entity.sourceFolderUri
|
||||||
|
|
||||||
if (folderUriString != null) {
|
if (folderUriString != null) {
|
||||||
|
val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f)
|
||||||
|
val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]"
|
||||||
|
val isDirty = entity.isRecent || hasProgress || hasBookmarks
|
||||||
|
|
||||||
|
if (!isDirty) {
|
||||||
|
Timber.d("SyncDebug: Book $bookId is 'Clean' (Unread/Not Recent). Skipping JSON creation.")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
Timber.d("Syncing metadata to local folder for book: $bookId")
|
Timber.d("Syncing metadata to local folder for book: $bookId")
|
||||||
|
|
||||||
val metadata = FolderBookMetadata(
|
val metadata = FolderBookMetadata(
|
||||||
|
|
@ -140,7 +149,7 @@ class RecentFilesRepository(private val context: Context) {
|
||||||
)
|
)
|
||||||
|
|
||||||
LocalSyncUtils.saveMetadataToFolder(
|
LocalSyncUtils.saveMetadataToFolder(
|
||||||
context = context, // Now correctly references the property
|
context = context,
|
||||||
sourceFolderUri = folderUriString.toUri(),
|
sourceFolderUri = folderUriString.toUri(),
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue