Desktop app (#308)

* 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
This commit is contained in:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -4,6 +4,7 @@ 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
@ -13,6 +14,11 @@ 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
@ -24,6 +30,7 @@ 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
@ -60,9 +67,16 @@ object DesktopLocalFolderSync {
state: SharedReaderScreenState,
shelfRefs: List<BookShelfRef>,
targetFolder: File? = null,
nowMillis: Long = System.currentTimeMillis()
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()
@ -74,18 +88,36 @@ object DesktopLocalFolderSync {
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
}
val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString)
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
nowMillis = nowMillis,
metadataOnly = metadataOnly
)
nextState = syncResult.state
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
@ -95,25 +127,46 @@ object DesktopLocalFolderSync {
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 }
importAnnotationSidecars(root, syncedBooks)
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
books = nextState.rawLibraryBooks,
sourceFolder = folder.uriString
logDesktopFolderSync(
"folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}"
)
if (metadataResult.stats.updatedBooks > 0) {
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
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}"
)
}
totalMetadataStats += metadataResult.stats
syncedBooks.forEach { book ->
saveBookMetadata(book)
savePdfAnnotationSidecar(book)
if (!metadataOnly) {
savePdfAnnotationSidecar(book)
}
}
logDesktopFolderSync(
"folder.done mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
"savedCandidates=${syncedBooks.size}"
)
}
return DesktopLocalFolderSyncResult(
val result = DesktopLocalFolderSyncResult(
state = nextState,
shelfRefs = nextShelfRefs,
stats = totalStats,
@ -122,6 +175,12 @@ object DesktopLocalFolderSync {
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) {
@ -130,18 +189,56 @@ object DesktopLocalFolderSync {
}
fun saveBookMetadata(book: BookItem) {
val metadata = book.toSharedFolderBookMetadata() ?: return
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
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() } ?: return
if (book.type != FileType.PDF) return
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
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()
@ -175,6 +272,9 @@ object DesktopLocalFolderSync {
}
}
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
}
@ -188,6 +288,10 @@ object DesktopLocalFolderSync {
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 " +
@ -249,35 +353,64 @@ object DesktopLocalFolderSync {
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
if (!syncDir.isDirectory) return emptyMap()
return syncDir.listFiles().orEmpty()
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 }
.mapNotNull { file -> file.metadataBookIdOrNull()?.let { it to file } }
.groupBy({ it.first }, { it.second })
.mapNotNull { (bookId, files) ->
val best = files
.mapNotNull { file ->
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
.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()}"
)
}
.filter { it.bookId == bookId }
.maxByOrNull { it.lastModifiedTimestamp }
best?.let { bookId to it }
.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) return
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, ".${metadata.bookId}.json")
val temp = File(syncDir, ".${metadata.bookId}.tmp")
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() }
}
}
@ -287,18 +420,33 @@ object DesktopLocalFolderSync {
bookId: String,
cleanup: Boolean
): SharedFolderBookMetadata? {
val hashedStem = localFolderSyncSidecarStem(bookId)
val candidates = syncDir.listFiles().orEmpty().filter { file ->
val normalized = file.name.removePrefix(".")
file.isFile && (
normalized == "$bookId.json" ||
normalized.startsWith("$bookId.sync-conflict") ||
normalized.startsWith("$bookId.json.sync-conflict")
)
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()) }.getOrNull()
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
@ -306,10 +454,21 @@ object DesktopLocalFolderSync {
if (cleanup) {
candidates
.filterNot { it == winner.first }
.forEach { runCatching { it.delete() } }
val correctName = ".${bookId}.json"
.forEach { file ->
runCatching { file.delete() }
logDesktopFolderSync(
"metadata.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
)
}
val correctName = localFolderSyncMetadataFileName(bookId)
if (winner.first.name != correctName) {
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
val target = File(syncDir, correctName)
runCatching { moveReplacing(winner.first, target) }
.onSuccess {
logDesktopFolderSync(
"metadata.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
)
}
}
}
@ -318,30 +477,61 @@ object DesktopLocalFolderSync {
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
if (!syncDir.isDirectory) return emptyMap()
return syncDir.listFiles().orEmpty()
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 }
.mapNotNull { file -> file.annotationBookIdOrNull()?.let { it to file } }
.groupBy({ it.first }, { it.second })
.mapNotNull { (bookId, files) ->
val best = files
.mapNotNull { it.readAnnotationSidecarOrNull() }
.maxByOrNull { it.timestamp }
best?.let { bookId to it }
.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()) return
if (books.isEmpty()) {
logDesktopFolderSync("annotation.import.skipNoBooks root=\"${root.absolutePath.folderSyncPreview()}\"")
return
}
val sidecars = preloadAnnotationSidecars(root)
if (sidecars.isEmpty()) return
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() } ?: return@forEach
if (book.type != FileType.PDF) return@forEach
val sidecar = sidecars[book.id] ?: return@forEach
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)
@ -350,7 +540,14 @@ object DesktopLocalFolderSync {
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} " +
@ -365,11 +562,18 @@ object DesktopLocalFolderSync {
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)
@ -381,6 +585,10 @@ object DesktopLocalFolderSync {
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()}\""
)
}
}
}
@ -392,9 +600,20 @@ object DesktopLocalFolderSync {
timestamp: Long
) {
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() ?: return
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} " +
@ -407,15 +626,20 @@ object DesktopLocalFolderSync {
val wrapper = JsonObject(
mapOf(
"version" to JsonPrimitive(1),
"bookId" to JsonPrimitive(bookId),
"timestamp" to JsonPrimitive(timestamp),
"data" to data
)
)
val target = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json")
val temp = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.tmp")
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 " +
@ -423,6 +647,10 @@ object DesktopLocalFolderSync {
)
}
}.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 " +
@ -438,22 +666,40 @@ object DesktopLocalFolderSync {
bookId: String,
cleanup: Boolean
): AnnotationSidecar? {
val candidates = syncDir.listFiles().orEmpty().filter { file ->
file.isFile && file.annotationBookIdOrNull() == bookId
}
if (candidates.isEmpty()) return null
val parsed = candidates.mapNotNull { file ->
file.readAnnotationSidecarOrNull()?.let { file to it }
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) {
candidates
parsed.map { it.first }
.filterNot { it == winner.first }
.forEach { runCatching { it.delete() } }
val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json"
.forEach { file ->
runCatching { file.delete() }
logDesktopFolderSync(
"annotation.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
)
}
val correctName = localFolderSyncAnnotationFileName(bookId)
if (winner.first.name != correctName) {
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
val target = File(syncDir, correctName)
runCatching { moveReplacing(winner.first, target) }
.onSuccess {
logDesktopFolderSync(
"annotation.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
)
}
}
}
@ -462,6 +708,7 @@ object DesktopLocalFolderSync {
}
private data class AnnotationSidecar(
val bookId: String,
val timestamp: Long,
val data: JsonObject
)
@ -485,25 +732,23 @@ private fun File.shouldSyncBookFile(): Boolean {
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
}
private fun File.metadataBookIdOrNull(): String? {
private fun File.isMetadataSidecarCandidate(): Boolean {
val fileName = name
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
if (fileName.endsWith(".tmp") || fileName.contains(".syncthing.")) return null
if (!fileName.endsWith(".json") && !fileName.contains(".sync-conflict")) return null
val normalized = fileName.removePrefix(".")
val base = if (normalized.contains(".sync-conflict")) {
normalized.substringBefore(".sync-conflict")
} else {
normalized.substringBeforeLast(".json")
}
return base.removeSuffix(".json").takeIf { it.isNotBlank() }
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.annotationBookIdOrNull(): String? {
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 (!candidate.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
if (!candidate.endsWith(".json") || candidate.endsWith(".tmp")) return null
if (candidate.contains(".syncthing.")) return null
if (!isAnnotationSidecarCandidate()) return null
if (candidate.contains(".sync-conflict")) {
candidate = candidate.substringBefore(".sync-conflict")
}
@ -511,15 +756,37 @@ private fun File.annotationBookIdOrNull(): String? {
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
}
return candidate.removePrefix(".").takeIf { it.isNotBlank() }
val normalized = candidate.removePrefix(".")
if (normalized.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null
return normalized.takeIf { it.isNotBlank() }
}
private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? {
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(timestamp = timestamp, data = data)
AnnotationSidecar(bookId = bookId, timestamp = timestamp, data = data)
}.onFailure { error ->
logDesktopFolderSync(
"annotation.read.parseFailed file=\"${absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
)
}.getOrNull()
}
@ -547,6 +814,12 @@ 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()