* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
846 lines
37 KiB
Kotlin
846 lines
37 KiB
Kotlin
package com.aryan.reader.desktop
|
|
|
|
import com.aryan.reader.shared.BookItem
|
|
import com.aryan.reader.shared.BookShelfRef
|
|
import com.aryan.reader.shared.FileType
|
|
import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX
|
|
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
|
|
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
|
import com.aryan.reader.shared.LocalFolderSyncEngine
|
|
import com.aryan.reader.shared.LocalFolderSyncStats
|
|
import com.aryan.reader.shared.ReaderPlatform
|
|
import com.aryan.reader.shared.SharedFileCapabilities
|
|
import com.aryan.reader.shared.SharedFolderBookMetadata
|
|
import com.aryan.reader.shared.SharedFolderScannedFile
|
|
import com.aryan.reader.shared.SharedReaderScreenState
|
|
import com.aryan.reader.shared.SyncedFolder
|
|
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
|
|
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
|
|
import com.aryan.reader.shared.localFolderSyncMetadataFileName
|
|
import com.aryan.reader.shared.localFolderSyncMetadataTempFileName
|
|
import com.aryan.reader.shared.localFolderSyncSidecarStem
|
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
|
|
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
|
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
|
|
import com.aryan.reader.shared.pdf.SharedPdfRichTextSerializer
|
|
import com.aryan.reader.shared.toSharedFolderBookMetadata
|
|
import kotlinx.serialization.encodeToString
|
|
import kotlinx.serialization.json.Json
|
|
import kotlinx.serialization.json.JsonElement
|
|
import kotlinx.serialization.json.JsonNull
|
|
import kotlinx.serialization.json.JsonObject
|
|
import kotlinx.serialization.json.JsonPrimitive
|
|
import kotlinx.serialization.json.contentOrNull
|
|
import kotlinx.serialization.json.jsonObject
|
|
import kotlinx.serialization.json.jsonPrimitive
|
|
import kotlinx.serialization.json.longOrNull
|
|
import java.io.File
|
|
import java.nio.file.AtomicMoveNotSupportedException
|
|
import java.nio.file.Files
|
|
import java.nio.file.StandardCopyOption
|
|
|
|
data class DesktopLocalFolderSyncResult(
|
|
val state: SharedReaderScreenState,
|
|
val shelfRefs: List<BookShelfRef>,
|
|
val stats: LocalFolderSyncStats,
|
|
val metadataStats: DesktopFolderMetadataExtractionStats = DesktopFolderMetadataExtractionStats(),
|
|
val idMigrations: Map<String, String> = emptyMap(),
|
|
val removedBookIds: Set<String> = emptySet(),
|
|
val failedFolders: List<String> = emptyList()
|
|
)
|
|
|
|
object DesktopLocalFolderSync {
|
|
private val desktopSyncableTypes = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.DESKTOP)
|
|
|
|
fun hasSupportedFiles(folder: File): Boolean {
|
|
if (!folder.isDirectory) return false
|
|
return folder.walkTopDown()
|
|
.onEnter { it == folder || it.shouldEnterSyncedFolder() }
|
|
.any { file ->
|
|
file.isFile &&
|
|
file.shouldSyncBookFile() &&
|
|
SharedFileCapabilities.fileTypeForName(file.name) in desktopSyncableTypes
|
|
}
|
|
}
|
|
|
|
fun sync(
|
|
state: SharedReaderScreenState,
|
|
shelfRefs: List<BookShelfRef>,
|
|
targetFolder: File? = null,
|
|
nowMillis: Long = System.currentTimeMillis(),
|
|
metadataOnly: Boolean = false
|
|
): DesktopLocalFolderSyncResult {
|
|
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
|
|
val mode = if (metadataOnly) "metadata" else "full"
|
|
logDesktopFolderSync(
|
|
"sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " +
|
|
"requestedFolders=${requestedFolders.size} linkedFolders=${state.syncedFolders.size} " +
|
|
"books=${state.rawLibraryBooks.size}"
|
|
)
|
|
var nextState = state
|
|
var nextShelfRefs = shelfRefs
|
|
var totalStats = LocalFolderSyncStats()
|
|
var totalMetadataStats = DesktopFolderMetadataExtractionStats()
|
|
val allMigrations = linkedMapOf<String, String>()
|
|
val allRemovedBookIds = linkedSetOf<String>()
|
|
val failedFolders = mutableListOf<String>()
|
|
|
|
requestedFolders.forEach { folder ->
|
|
val root = File(folder.uriString)
|
|
if (!root.isDirectory) {
|
|
logDesktopFolderSync(
|
|
"folder.skipMissing mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
|
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
failedFolders += folder.name
|
|
return@forEach
|
|
}
|
|
|
|
logDesktopFolderSync(
|
|
"folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
|
"root=\"${root.absolutePath.folderSyncPreview()}\" allowed=${folder.allowedFileTypes.sortedBy { it.name }}"
|
|
)
|
|
val scannedFiles = if (metadataOnly) {
|
|
emptyList()
|
|
} else {
|
|
scanFolder(root = root, sourceFolder = folder.uriString)
|
|
}
|
|
val remoteMetadata = readAllMetadata(root)
|
|
logDesktopFolderSync(
|
|
"folder.inputs mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
|
"scanned=${scannedFiles.size} supported=${scannedFiles.count { it.type in folder.allowedFileTypes }} " +
|
|
"remoteMetadata=${remoteMetadata.size}"
|
|
)
|
|
val syncResult = LocalFolderSyncEngine.syncFolder(
|
|
state = nextState,
|
|
folder = folder,
|
|
files = scannedFiles,
|
|
remoteMetadata = remoteMetadata,
|
|
nowMillis = nowMillis,
|
|
metadataOnly = metadataOnly
|
|
)
|
|
nextState = syncResult.state
|
|
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
|
|
nextShelfRefs,
|
|
syncResult.idMigrations
|
|
).filterNot { it.bookId in syncResult.removedBookIds }
|
|
allMigrations += syncResult.idMigrations
|
|
allRemovedBookIds += syncResult.removedBookIds
|
|
totalStats += syncResult.stats
|
|
logDesktopFolderSync(
|
|
"folder.engine mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
|
"new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
|
|
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} removed=${syncResult.stats.removedBooks} " +
|
|
"migrated=${syncResult.stats.migratedBooks} idMigrations=${syncResult.idMigrations.size}"
|
|
)
|
|
|
|
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
|
logDesktopFolderSync(
|
|
"folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}"
|
|
)
|
|
importAnnotationSidecars(root, syncedBooks)
|
|
if (!metadataOnly) {
|
|
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
|
|
books = nextState.rawLibraryBooks,
|
|
sourceFolder = folder.uriString
|
|
)
|
|
if (metadataResult.stats.updatedBooks > 0) {
|
|
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
|
|
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
|
}
|
|
totalMetadataStats += metadataResult.stats
|
|
logDesktopFolderSync(
|
|
"folder.metadataExtraction name=\"${folder.name.folderSyncPreview()}\" " +
|
|
"updated=${metadataResult.stats.updatedBooks} covers=${metadataResult.stats.coversUpdated}"
|
|
)
|
|
}
|
|
syncedBooks.forEach { book ->
|
|
saveBookMetadata(book)
|
|
if (!metadataOnly) {
|
|
savePdfAnnotationSidecar(book)
|
|
}
|
|
}
|
|
logDesktopFolderSync(
|
|
"folder.done mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
|
"savedCandidates=${syncedBooks.size}"
|
|
)
|
|
}
|
|
|
|
val result = DesktopLocalFolderSyncResult(
|
|
state = nextState,
|
|
shelfRefs = nextShelfRefs,
|
|
stats = totalStats,
|
|
metadataStats = totalMetadataStats,
|
|
idMigrations = allMigrations,
|
|
removedBookIds = allRemovedBookIds,
|
|
failedFolders = failedFolders
|
|
)
|
|
logDesktopFolderSync(
|
|
"sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " +
|
|
"updated=${totalStats.updatedBooks} remoteUpdates=${totalStats.remoteMetadataUpdates} " +
|
|
"removed=${totalStats.removedBooks} metadataExtracted=${totalMetadataStats.updatedBooks}"
|
|
)
|
|
return result
|
|
}
|
|
|
|
fun saveBookSidecars(book: BookItem) {
|
|
saveBookMetadata(book)
|
|
savePdfAnnotationSidecar(book)
|
|
}
|
|
|
|
fun saveBookMetadata(book: BookItem) {
|
|
val metadata = book.toSharedFolderBookMetadata()
|
|
if (metadata == null) {
|
|
logDesktopFolderSync(
|
|
"metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " +
|
|
"progress=${book.progressPercentage} recent=${book.isRecent} bookmarks=${book.readerBookmarks.size} " +
|
|
"highlights=${book.readerHighlights.size}"
|
|
)
|
|
return
|
|
}
|
|
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory }
|
|
if (root == null) {
|
|
logDesktopFolderSync(
|
|
"metadata.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
|
|
)
|
|
return
|
|
}
|
|
logDesktopFolderSync(
|
|
"metadata.export.request book=${book.id} timestamp=${metadata.lastModifiedTimestamp} " +
|
|
"progress=${metadata.progressPercentage} recent=${metadata.isRecent} " +
|
|
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
saveMetadataToFolder(root, metadata)
|
|
}
|
|
|
|
fun savePdfAnnotationSidecar(book: BookItem) {
|
|
val path = book.path?.takeIf { it.isNotBlank() }
|
|
if (path == null) {
|
|
logDesktopFolderSync("annotation.export.skipNoPath book=${book.id}")
|
|
return
|
|
}
|
|
if (book.type != FileType.PDF) {
|
|
logDesktopFolderSync("annotation.export.skipNonPdf book=${book.id} type=${book.type}")
|
|
return
|
|
}
|
|
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory }
|
|
if (root == null) {
|
|
logDesktopFolderSync(
|
|
"annotation.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
|
|
)
|
|
return
|
|
}
|
|
val annotationFile = desktopPdfAnnotationFile(path)
|
|
val bookmarkFile = desktopPdfBookmarkFile(path)
|
|
val richTextFile = desktopPdfRichTextFile(path)
|
|
logDesktopFolderSync(
|
|
"annotation.export.check book=${book.id} root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
|
"pdfPath=\"${path.folderSyncPreview()}\" hasAnnotations=${annotationFile.isFile} " +
|
|
"hasBookmarks=${bookmarkFile.isFile} hasText=${richTextFile.isFile} " +
|
|
"localTs=${maxOf(annotationFile.lastModifiedIfFile(), bookmarkFile.lastModifiedIfFile(), richTextFile.lastModifiedIfFile())}"
|
|
)
|
|
val data = buildMap {
|
|
if (annotationFile.isFile) {
|
|
val annotationJson = annotationFile.readText().trim()
|
|
val annotations = SharedPdfAnnotationSerializer.decode(annotationJson)
|
|
put(
|
|
SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS,
|
|
SharedPdfAnnotationSidecarCodec.encodeAnnotationsElement(annotations)
|
|
)
|
|
}
|
|
if (bookmarkFile.isFile) {
|
|
val bookmarksJson = bookmarkFile.readText().trim()
|
|
desktopFolderSyncJson.parseElementOrNull(bookmarksJson)?.let { put("bookmarks", it) }
|
|
}
|
|
if (richTextFile.isFile) {
|
|
val richTextJson = richTextFile.readText().trim()
|
|
val richTextElement = desktopFolderSyncJson.parseElementOrNull(richTextJson)
|
|
if (richTextElement == null) {
|
|
SharedPdfRichTextLog.d(
|
|
"desktop.sync.exportRichTextParseFailed book=${book.id} " +
|
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length}"
|
|
)
|
|
} else {
|
|
val richTextDocument = SharedPdfRichTextSerializer.decodeElement(richTextElement)
|
|
SharedPdfRichTextLog.d(
|
|
"desktop.sync.exportRichText book=${book.id} " +
|
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\" rawLen=${richTextJson.length} " +
|
|
"textLen=${richTextDocument.text.length} spans=${richTextDocument.spans.size}"
|
|
)
|
|
put("text", SharedPdfRichTextSerializer.encodeElement(richTextDocument))
|
|
}
|
|
}
|
|
}
|
|
if (data.isEmpty()) {
|
|
logDesktopFolderSync(
|
|
"annotation.export.skipNoLocalData book=${book.id} pdfPath=\"${path.folderSyncPreview()}\""
|
|
)
|
|
SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"")
|
|
return
|
|
}
|
|
val timestamp = maxOf(
|
|
annotationFile.lastModifiedIfFile(),
|
|
bookmarkFile.lastModifiedIfFile(),
|
|
richTextFile.lastModifiedIfFile(),
|
|
System.currentTimeMillis()
|
|
)
|
|
val dataJson = desktopFolderSyncJson.encodeToString(
|
|
JsonElement.serializer(),
|
|
JsonObject(data)
|
|
)
|
|
logDesktopFolderSync(
|
|
"annotation.export.request book=${book.id} timestamp=$timestamp keys=${data.keys.sorted()} " +
|
|
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
if (data.containsKey("text")) {
|
|
SharedPdfRichTextLog.d(
|
|
"desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " +
|
|
"keys=${data.keys.sorted()} root=\"${root.absolutePath.richSyncPreview()}\""
|
|
)
|
|
}
|
|
saveAnnotationSidecar(
|
|
root = root,
|
|
bookId = book.id,
|
|
jsonPayload = dataJson,
|
|
timestamp = timestamp
|
|
)
|
|
}
|
|
|
|
private fun foldersToSync(
|
|
state: SharedReaderScreenState,
|
|
targetFolder: File?,
|
|
nowMillis: Long
|
|
): List<SyncedFolder> {
|
|
if (targetFolder == null) return state.syncedFolders
|
|
val root = targetFolder.canonicalOrAbsolute()
|
|
val rootPath = root.absolutePath
|
|
val existing = state.syncedFolders.firstOrNull { File(it.uriString).canonicalOrAbsolute() == root }
|
|
return listOf(
|
|
existing ?: SyncedFolder(
|
|
uriString = rootPath,
|
|
name = root.name.takeIf { it.isNotBlank() } ?: rootPath,
|
|
lastScanTime = nowMillis,
|
|
allowedFileTypes = desktopSyncableTypes
|
|
)
|
|
)
|
|
}
|
|
|
|
private fun scanFolder(root: File, sourceFolder: String): List<SharedFolderScannedFile> {
|
|
val rootPath = root.toPath().toAbsolutePath().normalize()
|
|
return root.walkTopDown()
|
|
.onEnter { it == root || it.shouldEnterSyncedFolder() }
|
|
.filter { it.isFile && it.shouldSyncBookFile() }
|
|
.mapNotNull { file ->
|
|
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
|
.takeIf { it in desktopSyncableTypes }
|
|
?: return@mapNotNull null
|
|
val relativePath = runCatching {
|
|
rootPath.relativize(file.toPath().toAbsolutePath().normalize())
|
|
.joinToString("/")
|
|
}.getOrNull() ?: file.name
|
|
SharedFolderScannedFile(
|
|
name = file.name,
|
|
path = file.absolutePath,
|
|
sourceFolder = sourceFolder,
|
|
relativePath = relativePath,
|
|
type = type,
|
|
size = file.length(),
|
|
lastModified = file.lastModified()
|
|
)
|
|
}
|
|
.toList()
|
|
}
|
|
|
|
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
|
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
|
if (!syncDir.isDirectory) {
|
|
logDesktopFolderSync("metadata.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"")
|
|
return emptyMap()
|
|
}
|
|
var candidates = 0
|
|
var parsed = 0
|
|
var failed = 0
|
|
val result = syncDir.listFiles().orEmpty()
|
|
.asSequence()
|
|
.filter { it.isFile && it.isMetadataSidecarCandidate() }
|
|
.mapNotNull { file ->
|
|
candidates++
|
|
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }
|
|
.onSuccess { parsed++ }
|
|
.onFailure { error ->
|
|
failed++
|
|
logDesktopFolderSync(
|
|
"metadata.read.parseFailed file=\"${file.absolutePath.folderSyncPreview()}\" " +
|
|
"error=${error.folderSyncSummary()}"
|
|
)
|
|
}
|
|
.getOrNull()
|
|
}
|
|
.groupBy { it.bookId }
|
|
.mapValues { (_, metadata) -> metadata.maxBy { it.lastModifiedTimestamp } }
|
|
.toMap()
|
|
logDesktopFolderSync(
|
|
"metadata.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
|
"candidates=$candidates parsed=$parsed failed=$failed winners=${result.size}"
|
|
)
|
|
return result
|
|
}
|
|
|
|
private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) {
|
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
|
val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true)
|
|
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
|
logDesktopFolderSync(
|
|
"metadata.save.skipNewerRemote book=${metadata.bookId} existingTs=${existing.lastModifiedTimestamp} " +
|
|
"candidateTs=${metadata.lastModifiedTimestamp} root=\"${root.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
return
|
|
}
|
|
|
|
val target = File(syncDir, localFolderSyncMetadataFileName(metadata.bookId))
|
|
val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId)))
|
|
runCatching {
|
|
temp.writeText(metadata.toJsonString())
|
|
moveReplacing(temp, target)
|
|
logDesktopFolderSync(
|
|
"metadata.save.done book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " +
|
|
"target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}"
|
|
)
|
|
}.onFailure {
|
|
logDesktopFolderSync(
|
|
"metadata.save.failed book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " +
|
|
"target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}"
|
|
)
|
|
runCatching { temp.delete() }
|
|
}
|
|
}
|
|
|
|
private fun resolveMetadataConflicts(
|
|
syncDir: File,
|
|
bookId: String,
|
|
cleanup: Boolean
|
|
): SharedFolderBookMetadata? {
|
|
val hashedStem = localFolderSyncSidecarStem(bookId)
|
|
val candidates = syncDir.listFiles().orEmpty().filter { file ->
|
|
val normalized = file.normalizedSidecarName()
|
|
file.isFile &&
|
|
file.isMetadataSidecarCandidate() &&
|
|
(
|
|
normalized.matchesJsonSidecarStem(hashedStem) ||
|
|
normalized.matchesJsonSidecarStem(bookId)
|
|
)
|
|
}
|
|
if (candidates.isEmpty()) return null
|
|
if (candidates.size > 1) {
|
|
logDesktopFolderSync(
|
|
"metadata.conflicts book=$bookId candidates=${candidates.size} " +
|
|
"dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup"
|
|
)
|
|
}
|
|
|
|
val parsed = candidates.mapNotNull { file ->
|
|
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }
|
|
.onFailure { error ->
|
|
logDesktopFolderSync(
|
|
"metadata.conflict.parseFailed book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\" " +
|
|
"error=${error.folderSyncSummary()}"
|
|
)
|
|
}
|
|
.getOrNull()
|
|
metadata?.takeIf { it.bookId == bookId }?.let { file to it }
|
|
}
|
|
val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null
|
|
|
|
if (cleanup) {
|
|
candidates
|
|
.filterNot { it == winner.first }
|
|
.forEach { file ->
|
|
runCatching { file.delete() }
|
|
logDesktopFolderSync(
|
|
"metadata.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
}
|
|
val correctName = localFolderSyncMetadataFileName(bookId)
|
|
if (winner.first.name != correctName) {
|
|
val target = File(syncDir, correctName)
|
|
runCatching { moveReplacing(winner.first, target) }
|
|
.onSuccess {
|
|
logDesktopFolderSync(
|
|
"metadata.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return winner.second
|
|
}
|
|
|
|
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
|
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
|
if (!syncDir.isDirectory) {
|
|
logDesktopFolderSync("annotation.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"")
|
|
return emptyMap()
|
|
}
|
|
var candidates = 0
|
|
var parsed = 0
|
|
val result = syncDir.listFiles().orEmpty()
|
|
.asSequence()
|
|
.filter { it.isFile && it.isAnnotationSidecarCandidate() }
|
|
.mapNotNull { file ->
|
|
candidates++
|
|
file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull())
|
|
?.also { parsed++ }
|
|
}
|
|
.groupBy { it.bookId }
|
|
.mapValues { (_, sidecars) -> sidecars.maxBy { it.timestamp } }
|
|
.toMap()
|
|
logDesktopFolderSync(
|
|
"annotation.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
|
"candidates=$candidates parsed=$parsed winners=${result.size}"
|
|
)
|
|
return result
|
|
}
|
|
|
|
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
|
|
if (books.isEmpty()) {
|
|
logDesktopFolderSync("annotation.import.skipNoBooks root=\"${root.absolutePath.folderSyncPreview()}\"")
|
|
return
|
|
}
|
|
val sidecars = preloadAnnotationSidecars(root)
|
|
if (sidecars.isEmpty()) {
|
|
logDesktopFolderSync(
|
|
"annotation.import.skipNoSidecars root=\"${root.absolutePath.folderSyncPreview()}\" books=${books.size}"
|
|
)
|
|
return
|
|
}
|
|
|
|
books.forEach { book ->
|
|
val path = book.path?.takeIf { it.isNotBlank() }
|
|
if (path == null) {
|
|
logDesktopFolderSync("annotation.import.skipNoPath book=${book.id}")
|
|
return@forEach
|
|
}
|
|
if (book.type != FileType.PDF) {
|
|
logDesktopFolderSync("annotation.import.skipNonPdf book=${book.id} type=${book.type}")
|
|
return@forEach
|
|
}
|
|
val sidecar = sidecars[book.id]
|
|
if (sidecar == null) {
|
|
logDesktopFolderSync(
|
|
"annotation.import.skipNoMatchingSidecar book=${book.id} available=${sidecars.keys.size} " +
|
|
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
return@forEach
|
|
}
|
|
val annotationFile = desktopPdfAnnotationFile(path)
|
|
val bookmarkFile = desktopPdfBookmarkFile(path)
|
|
val richTextFile = desktopPdfRichTextFile(path)
|
|
val localTimestamp = maxOf(
|
|
annotationFile.lastModifiedIfFile(),
|
|
bookmarkFile.lastModifiedIfFile(),
|
|
richTextFile.lastModifiedIfFile()
|
|
)
|
|
logDesktopFolderSync(
|
|
"annotation.import.compare book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp " +
|
|
"keys=${sidecar.data.keys.sorted()}"
|
|
)
|
|
if (sidecar.timestamp <= localTimestamp + 1000L) {
|
|
logDesktopFolderSync(
|
|
"annotation.import.skipOlder book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp"
|
|
)
|
|
if (sidecar.data.containsKey("text") || richTextFile.isFile) {
|
|
SharedPdfRichTextLog.d(
|
|
"desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " +
|
|
"localTs=$localTimestamp hasSidecarText=${sidecar.data.containsKey("text")} " +
|
|
"richFile=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
|
)
|
|
}
|
|
return@forEach
|
|
}
|
|
if (sidecar.data.hasPdfAnnotationPayload()) {
|
|
val annotations = SharedPdfAnnotationSidecarCodec.annotationsFromData(sidecar.data)
|
|
annotationFile.parentFile?.mkdirs()
|
|
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
|
annotationFile.setLastModified(sidecar.timestamp)
|
|
logDesktopFolderSync(
|
|
"annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " +
|
|
"file=\"${annotationFile.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
}
|
|
sidecar.data["bookmarks"]?.let { bookmarks ->
|
|
bookmarkFile.parentFile?.mkdirs()
|
|
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
|
|
bookmarkFile.setLastModified(sidecar.timestamp)
|
|
logDesktopFolderSync(
|
|
"annotation.import.writeBookmarks book=${book.id} file=\"${bookmarkFile.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
}
|
|
sidecar.data["text"]?.let { richText ->
|
|
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
|
|
SharedPdfRichTextLog.d(
|
|
"desktop.sync.importRichText book=${book.id} timestamp=${sidecar.timestamp} " +
|
|
"textLen=${richDocument.text.length} spans=${richDocument.spans.size} " +
|
|
"file=\"${richTextFile.absolutePath.richSyncPreview()}\""
|
|
)
|
|
richTextFile.parentFile?.mkdirs()
|
|
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
|
richTextFile.setLastModified(sidecar.timestamp)
|
|
logDesktopFolderSync(
|
|
"annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " +
|
|
"spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun saveAnnotationSidecar(
|
|
root: File,
|
|
bookId: String,
|
|
jsonPayload: String,
|
|
timestamp: Long
|
|
) {
|
|
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
|
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull()
|
|
if (data == null) {
|
|
logDesktopFolderSync(
|
|
"annotation.save.skipInvalidPayload book=$bookId timestamp=$timestamp " +
|
|
"root=\"${root.absolutePath.folderSyncPreview()}\" payloadLen=${jsonPayload.length}"
|
|
)
|
|
return
|
|
}
|
|
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
|
|
if (existing != null && existing.timestamp >= timestamp) {
|
|
logDesktopFolderSync(
|
|
"annotation.save.skipNewerExisting book=$bookId existingTs=${existing.timestamp} " +
|
|
"candidateTs=$timestamp root=\"${root.absolutePath.folderSyncPreview()}\" keys=${data.keys.sorted()}"
|
|
)
|
|
if (data.containsKey("text")) {
|
|
SharedPdfRichTextLog.d(
|
|
"desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " +
|
|
"candidateTs=$timestamp targetRoot=\"${root.absolutePath.richSyncPreview()}\""
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
val wrapper = JsonObject(
|
|
mapOf(
|
|
"version" to JsonPrimitive(1),
|
|
"bookId" to JsonPrimitive(bookId),
|
|
"timestamp" to JsonPrimitive(timestamp),
|
|
"data" to data
|
|
)
|
|
)
|
|
val target = File(syncDir, localFolderSyncAnnotationFileName(bookId))
|
|
val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId)))
|
|
runCatching {
|
|
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
|
|
moveReplacing(temp, target)
|
|
logDesktopFolderSync(
|
|
"annotation.save.done book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " +
|
|
"target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}"
|
|
)
|
|
if (data.containsKey("text")) {
|
|
SharedPdfRichTextLog.d(
|
|
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
|
|
"target=\"${target.absolutePath.richSyncPreview()}\""
|
|
)
|
|
}
|
|
}.onFailure {
|
|
logDesktopFolderSync(
|
|
"annotation.save.failed book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " +
|
|
"target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}"
|
|
)
|
|
if (data.containsKey("text")) {
|
|
SharedPdfRichTextLog.d(
|
|
"desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " +
|
|
"target=\"${target.absolutePath.richSyncPreview()}\" error=${it.message}"
|
|
)
|
|
}
|
|
runCatching { temp.delete() }
|
|
}
|
|
}
|
|
|
|
private fun resolveAnnotationConflicts(
|
|
syncDir: File,
|
|
bookId: String,
|
|
cleanup: Boolean
|
|
): AnnotationSidecar? {
|
|
val parsed = syncDir.listFiles().orEmpty()
|
|
.filter { file -> file.isFile && file.isAnnotationSidecarCandidate() }
|
|
.mapNotNull { file ->
|
|
file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull())
|
|
?.takeIf { it.bookId == bookId }
|
|
?.let { file to it }
|
|
}
|
|
if (parsed.isEmpty()) return null
|
|
if (parsed.size > 1) {
|
|
logDesktopFolderSync(
|
|
"annotation.conflicts book=$bookId candidates=${parsed.size} " +
|
|
"dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup"
|
|
)
|
|
}
|
|
val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
|
|
|
|
if (cleanup) {
|
|
parsed.map { it.first }
|
|
.filterNot { it == winner.first }
|
|
.forEach { file ->
|
|
runCatching { file.delete() }
|
|
logDesktopFolderSync(
|
|
"annotation.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
}
|
|
val correctName = localFolderSyncAnnotationFileName(bookId)
|
|
if (winner.first.name != correctName) {
|
|
val target = File(syncDir, correctName)
|
|
runCatching { moveReplacing(winner.first, target) }
|
|
.onSuccess {
|
|
logDesktopFolderSync(
|
|
"annotation.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return winner.second
|
|
}
|
|
}
|
|
|
|
private data class AnnotationSidecar(
|
|
val bookId: String,
|
|
val timestamp: Long,
|
|
val data: JsonObject
|
|
)
|
|
|
|
private val desktopFolderSyncJson = Json {
|
|
ignoreUnknownKeys = true
|
|
prettyPrint = true
|
|
encodeDefaults = true
|
|
}
|
|
|
|
private fun File.shouldEnterSyncedFolder(): Boolean {
|
|
if (!isDirectory) return false
|
|
if (name == LOCAL_FOLDER_SYNC_DATA_DIR) return false
|
|
if (name.startsWith(".")) return false
|
|
return runCatching { !isHidden }.getOrDefault(true)
|
|
}
|
|
|
|
private fun File.shouldSyncBookFile(): Boolean {
|
|
if (name.startsWith(".")) return false
|
|
if (extension.equals("json", ignoreCase = true)) return false
|
|
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
|
|
}
|
|
|
|
private fun File.isMetadataSidecarCandidate(): Boolean {
|
|
val fileName = name
|
|
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false
|
|
if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false
|
|
return fileName.endsWith(".json") || fileName.contains(".sync-conflict")
|
|
}
|
|
|
|
private fun File.isAnnotationSidecarCandidate(): Boolean {
|
|
val fileName = name
|
|
if (!fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false
|
|
if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false
|
|
return fileName.endsWith(".json") || fileName.contains(".sync-conflict")
|
|
}
|
|
|
|
private fun File.legacyAnnotationBookIdOrNull(): String? {
|
|
var candidate = name
|
|
if (!isAnnotationSidecarCandidate()) return null
|
|
if (candidate.contains(".sync-conflict")) {
|
|
candidate = candidate.substringBefore(".sync-conflict")
|
|
}
|
|
candidate = candidate.substringBeforeLast(".json")
|
|
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
|
|
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
|
|
}
|
|
val normalized = candidate.removePrefix(".")
|
|
if (normalized.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null
|
|
return normalized.takeIf { it.isNotBlank() }
|
|
}
|
|
|
|
private fun File.normalizedSidecarName(): String {
|
|
return name.removePrefix(".")
|
|
}
|
|
|
|
private fun String.matchesJsonSidecarStem(stem: String): Boolean {
|
|
return this == "$stem.json" ||
|
|
startsWith("$stem.sync-conflict") ||
|
|
startsWith("$stem.json.sync-conflict")
|
|
}
|
|
|
|
private fun File.readAnnotationSidecarOrNull(fallbackBookId: String? = null): AnnotationSidecar? {
|
|
return runCatching {
|
|
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
|
|
val bookId = root["bookId"]
|
|
?.takeUnless { it is JsonNull }
|
|
?.jsonPrimitive
|
|
?.contentOrNull
|
|
?: fallbackBookId
|
|
?: error("Missing annotation sidecar bookId")
|
|
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
|
|
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
|
|
AnnotationSidecar(bookId = bookId, timestamp = timestamp, data = data)
|
|
}.onFailure { error ->
|
|
logDesktopFolderSync(
|
|
"annotation.read.parseFailed file=\"${absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
|
|
)
|
|
}.getOrNull()
|
|
}
|
|
|
|
private fun Json.parseElementOrNull(raw: String): JsonElement? {
|
|
return runCatching { parseToJsonElement(raw) }.getOrNull()
|
|
}
|
|
|
|
private fun JsonElement.jsonObjectOrNull(): JsonObject? {
|
|
if (this is JsonNull) return null
|
|
return runCatching { jsonObject }.getOrNull()
|
|
}
|
|
|
|
private fun JsonObject.hasPdfAnnotationPayload(): Boolean {
|
|
return containsKey(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATIONS) ||
|
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_INK) ||
|
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_TEXT_BOXES) ||
|
|
containsKey(SharedPdfAnnotationSidecarCodec.KEY_LEGACY_HIGHLIGHTS)
|
|
}
|
|
|
|
private fun File.canonicalOrAbsolute(): File {
|
|
return runCatching { canonicalFile }.getOrElse { absoluteFile }
|
|
}
|
|
|
|
private fun File.lastModifiedIfFile(): Long {
|
|
return if (isFile) lastModified() else 0L
|
|
}
|
|
|
|
private fun uniqueFolderSyncTempName(baseName: String): String {
|
|
val stem = baseName.removeSuffix(".tmp")
|
|
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"
|
|
return "$stem.$nonce.tmp"
|
|
}
|
|
|
|
private fun String.richSyncPreview(maxLength: Int = 160): String {
|
|
return replace(Regex("\\s+"), " ")
|
|
.trim()
|
|
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
|
.replace("\"", "\\\"")
|
|
}
|
|
|
|
private fun moveReplacing(source: File, target: File) {
|
|
target.parentFile?.mkdirs()
|
|
try {
|
|
Files.move(
|
|
source.toPath(),
|
|
target.toPath(),
|
|
StandardCopyOption.REPLACE_EXISTING,
|
|
StandardCopyOption.ATOMIC_MOVE
|
|
)
|
|
} catch (_: AtomicMoveNotSupportedException) {
|
|
Files.move(
|
|
source.toPath(),
|
|
target.toPath(),
|
|
StandardCopyOption.REPLACE_EXISTING
|
|
)
|
|
}
|
|
}
|