Windows (#291)
* Centralize library management logic and introduce support for plain text and HTML formats * Centralize library management logic and introduce support for plain text and HTML formats * Expand unit test coverage for library state management, UI models, and MainViewModel features. * Add comprehensive unit tests for PDF reader core logic, preferences, and data persistence * Add unit tests for EPUB parsing, content loading, search functionality, and reader JavaScript bridges. * Add unit tests for OPDS parsing and Smart Collection engine, and integrate Kover plugin * Add comprehensive unit tests * Centralize library snapshot serialization in the `shared` module and improve filtering and sorting logic. * Implement text selection, highlighting, and reading state persistence for PDF and EPUB engines in desktop version * Folder import support for desktop app * Introduce Smart Shelves with rule-based filtering in desktop version * Implement shared EPUB annotation serialization and highlight rendering * Centralize file type capabilities and platform-specific support logic * Refactor reader state management to use a central reducer * Implement customizable reader toolbar and advanced formatting settings in shared * Implement locator-based navigation and customizable highlight palette for desktop app * Enhance reader customization and expand search functionality in desktop app * Redesign reader settings and tools into a tabbed control panel in desktop app * Enhance reader navigation and highlight precision in desktop app * Implement bidirectional position synchronization and dynamic highlights in the desktop reader * Implement shared state management and enhanced search for the PDF reader in desktop app * Add vertical scroll support to the desktop PDF reader * Implement ink, text, and eraser annotation support in desktop PDF viewer * Implement PDF bookmarks, Table of Contents, and annotation editing in desktop app * Implement link handling and navigation for PDF and EPUB readers in desktop app * Implement PDF jump history for navigation in desktop app * Enhance PDF ink rendering and annotation capabilities in desktop app * Implement advanced PDF text annotations with inline editing and rich styling in desktop app * Add move handle and movement logic for PDF text annotations in desktop app * Implement local folder synchronization and metadata sidecar support in desktop app * Implement book metadata extraction and drag-and-drop import for Desktop * Implement dynamic and custom app theme management for desktop * Introduce canonical PDF annotation codec and support for multi-segment highlights * Implement rich text editing and pagination support for the PDF reader in desktop app * Improve PDF rich text pagination, synchronization, and observability in desktop * Hide trailing structural page breaks in rich text editor * Implement a unified JVM book loader and expand supported formats on Desktop * Add comic archive support for Desktop and enhance MOBI parsing * Implement shared OPDS catalog support and UI for Android and Desktop * Improve native WebView lifecycle and surface transition management on Desktop * Enable Compose Swing interop blending and simplify Desktop WebView management * Integrate BYOK AI features and Cloud TTS for desktop * Enhance Desktop TTS with streaming audio and improved secure storage for AI key * Implement scoped Cloud TTS with synchronized highlighting for EPUB and PDF in desktop app * Implement custom font management and utility screens in desktop app * Implement PDFium-based PDF annotation export * Remove PdfBox dependency and standardize PDF export via Pdfium * Implement local audio caching and playback controls for Gemini Cloud TTS in desktop app * Implement reader themes and custom texture support in desktop app * Redesign non-reader UI with responsive navigation and enhanced library management in desktop app * Introduce ReaderWorkspaceShell to unify EPUB and PDF reader layouts in desktop app * Exclude manual-only files from automated sync and import * Implement customizable Text-to-Speech (TTS) word replacements * Optimize reader performance with persistent layout caching and decoupled theme rendering * Improve position restoration during reader reconfiguration in epub pagination * Use independent thickness for eraser tool and stylus override
This commit is contained in:
parent
88c7fa7b5c
commit
8366d76dcd
214 changed files with 53372 additions and 4702 deletions
|
|
@ -0,0 +1,573 @@
|
|||
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_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.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.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()
|
||||
): DesktopLocalFolderSyncResult {
|
||||
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
|
||||
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) {
|
||||
failedFolders += folder.name
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString)
|
||||
val remoteMetadata = readAllMetadata(root)
|
||||
val syncResult = LocalFolderSyncEngine.syncFolder(
|
||||
state = nextState,
|
||||
folder = folder,
|
||||
files = scannedFiles,
|
||||
remoteMetadata = remoteMetadata,
|
||||
nowMillis = nowMillis
|
||||
)
|
||||
nextState = syncResult.state
|
||||
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
|
||||
nextShelfRefs,
|
||||
syncResult.idMigrations
|
||||
).filterNot { it.bookId in syncResult.removedBookIds }
|
||||
allMigrations += syncResult.idMigrations
|
||||
allRemovedBookIds += syncResult.removedBookIds
|
||||
totalStats += syncResult.stats
|
||||
|
||||
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||
importAnnotationSidecars(root, syncedBooks)
|
||||
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
|
||||
syncedBooks.forEach { book ->
|
||||
saveBookMetadata(book)
|
||||
savePdfAnnotationSidecar(book)
|
||||
}
|
||||
}
|
||||
|
||||
return DesktopLocalFolderSyncResult(
|
||||
state = nextState,
|
||||
shelfRefs = nextShelfRefs,
|
||||
stats = totalStats,
|
||||
metadataStats = totalMetadataStats,
|
||||
idMigrations = allMigrations,
|
||||
removedBookIds = allRemovedBookIds,
|
||||
failedFolders = failedFolders
|
||||
)
|
||||
}
|
||||
|
||||
fun saveBookSidecars(book: BookItem) {
|
||||
saveBookMetadata(book)
|
||||
savePdfAnnotationSidecar(book)
|
||||
}
|
||||
|
||||
fun saveBookMetadata(book: BookItem) {
|
||||
val metadata = book.toSharedFolderBookMetadata() ?: return
|
||||
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
|
||||
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 annotationFile = desktopPdfAnnotationFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
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()) {
|
||||
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)
|
||||
)
|
||||
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) return emptyMap()
|
||||
return 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.bookId == bookId }
|
||||
.maxByOrNull { it.lastModifiedTimestamp }
|
||||
best?.let { bookId to it }
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
val target = File(syncDir, ".${metadata.bookId}.json")
|
||||
val temp = File(syncDir, ".${metadata.bookId}.tmp")
|
||||
runCatching {
|
||||
temp.writeText(metadata.toJsonString())
|
||||
moveReplacing(temp, target)
|
||||
}.onFailure {
|
||||
runCatching { temp.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveMetadataConflicts(
|
||||
syncDir: File,
|
||||
bookId: String,
|
||||
cleanup: Boolean
|
||||
): SharedFolderBookMetadata? {
|
||||
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")
|
||||
)
|
||||
}
|
||||
if (candidates.isEmpty()) return null
|
||||
|
||||
val parsed = candidates.mapNotNull { file ->
|
||||
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.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 { runCatching { it.delete() } }
|
||||
val correctName = ".${bookId}.json"
|
||||
if (winner.first.name != correctName) {
|
||||
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
|
||||
}
|
||||
}
|
||||
|
||||
return winner.second
|
||||
}
|
||||
|
||||
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()
|
||||
.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 }
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
|
||||
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
|
||||
if (books.isEmpty()) return
|
||||
val sidecars = preloadAnnotationSidecars(root)
|
||||
if (sidecars.isEmpty()) 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 annotationFile = desktopPdfAnnotationFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
val localTimestamp = maxOf(
|
||||
annotationFile.lastModifiedIfFile(),
|
||||
bookmarkFile.lastModifiedIfFile(),
|
||||
richTextFile.lastModifiedIfFile()
|
||||
)
|
||||
if (sidecar.timestamp <= localTimestamp + 1000L) {
|
||||
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)
|
||||
}
|
||||
sidecar.data["bookmarks"]?.let { bookmarks ->
|
||||
bookmarkFile.parentFile?.mkdirs()
|
||||
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
|
||||
bookmarkFile.setLastModified(sidecar.timestamp)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() ?: return
|
||||
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
|
||||
if (existing != null && existing.timestamp >= timestamp) {
|
||||
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),
|
||||
"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")
|
||||
runCatching {
|
||||
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
|
||||
moveReplacing(temp, target)
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
|
||||
"target=\"${target.absolutePath.richSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
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 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 winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
|
||||
|
||||
if (cleanup) {
|
||||
candidates
|
||||
.filterNot { it == winner.first }
|
||||
.forEach { runCatching { it.delete() } }
|
||||
val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json"
|
||||
if (winner.first.name != correctName) {
|
||||
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
|
||||
}
|
||||
}
|
||||
|
||||
return winner.second
|
||||
}
|
||||
}
|
||||
|
||||
private data class AnnotationSidecar(
|
||||
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.metadataBookIdOrNull(): String? {
|
||||
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() }
|
||||
}
|
||||
|
||||
private fun File.annotationBookIdOrNull(): 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 (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)
|
||||
}
|
||||
return candidate.removePrefix(".").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? {
|
||||
return runCatching {
|
||||
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
|
||||
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
|
||||
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
|
||||
AnnotationSidecar(timestamp = timestamp, data = data)
|
||||
}.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 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue