Folder pdf annotation sync (#32)
* Implemented folder-based annotation synchronization and legacy book ID migration. - Added `syncLocalAnnotationsToFolder` and `importAnnotationBundle` to `RecentFilesRepository` to handle bundling ink, rich text, layouts, and text boxes into a single JSON sidecar file. - Introduced `saveAnnotationSidecar` and `getAnnotationSidecar` in `LocalSyncUtils` to manage hidden synchronization files in linked folders using SAF. - Updated `MainViewModel` to trigger annotation syncing on book close and added a migration utility for legacy book IDs. - Enhanced `FolderSyncWorker` to include a dedicated phase for importing newer annotation bundles from synchronized folders. - Fixed an ID mismatch edge case in `PdfViewerScreen` by initiating data migration when a legacy ID is detected. - Added `selectedBookId` to `ReaderScreenState` to better track the active document across the application. * fix(sync): resolve Syncthing file lock conflicts and annotation migration bug - Implement atomic saves (write-to-temp then rename) for annotation sidecars in `LocalSyncUtils` to prevent `.tmp` file locking and partial writes when using third-party sync tools like Syncthing. - Add robust conflict resolution for annotation sidecars (`resolveAndCleanAnnotationConflicts`) to automatically detect, keep the newest version, and clean up `.sync-conflict` files. - Fix a bug in `MainViewModel.checkAndMigrateLegacyBookId` where older legacy local files would blindly overwrite newly synced annotation files by adding a timestamp-based clobber check.
This commit is contained in:
parent
0b7bb53a28
commit
7e28c1c2b3
5 changed files with 464 additions and 57 deletions
|
|
@ -143,16 +143,9 @@ enum class SortOrder(val displayName: String) {
|
|||
)
|
||||
}
|
||||
|
||||
data class SyncUpdateInfo(
|
||||
val bookId: String,
|
||||
val locator: Locator?,
|
||||
val page: Int?,
|
||||
val cfi: String?,
|
||||
val bookmarksJson: String?
|
||||
)
|
||||
|
||||
data class ReaderScreenState(
|
||||
val selectedPdfUri: Uri? = null,
|
||||
val selectedBookId: String? = null,
|
||||
val selectedEpubBook: EpubBook? = null,
|
||||
val selectedEpubUri: Uri? = null,
|
||||
val selectedFileType: FileType? = null,
|
||||
|
|
@ -1221,6 +1214,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
it.copy(
|
||||
selectedPdfUri = null,
|
||||
selectedEpubUri = null,
|
||||
selectedBookId = null,
|
||||
selectedEpubBook = null,
|
||||
selectedFileType = null,
|
||||
isLoading = false,
|
||||
|
|
@ -1240,9 +1234,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
}
|
||||
|
||||
if (it.sourceFolderUri != null) {
|
||||
Timber.d("Book closed (Folder Linked), syncing metadata to folder: ${it.bookId}")
|
||||
Timber.tag("FolderAnnotationSync").d("Book closed (Folder Linked), syncing metadata and annotations to folder: ${it.bookId}")
|
||||
viewModelScope.launch {
|
||||
recentFilesRepository.syncLocalMetadataToFolder(it.bookId)
|
||||
recentFilesRepository.syncLocalAnnotationsToFolder(it.bookId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2266,6 +2261,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
it.copy(
|
||||
selectedPdfUri = null,
|
||||
selectedEpubUri = null,
|
||||
selectedBookId = bookId,
|
||||
selectedEpubBook = null,
|
||||
selectedFileType = type,
|
||||
isLoading = true,
|
||||
|
|
@ -3202,6 +3198,71 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
|||
Timber.d("ViewModel instance cleared (onCleared).")
|
||||
}
|
||||
|
||||
suspend fun checkAndMigrateLegacyBookId(legacyId: String, newId: String) = withContext(Dispatchers.IO) {
|
||||
if (legacyId == newId) return@withContext
|
||||
Timber.tag("FolderAnnotationSync").d("Checking migration from legacyId=$legacyId to newId=$newId")
|
||||
|
||||
try {
|
||||
fun safeMigrate(legacyFile: File?, newFile: File?, tag: String) {
|
||||
if (legacyFile != null && legacyFile.exists()) {
|
||||
if (newFile != null) {
|
||||
if (newFile.exists()) {
|
||||
val legacyTs = legacyFile.lastModified()
|
||||
val newTs = newFile.lastModified()
|
||||
|
||||
if (newTs > legacyTs) {
|
||||
Timber.tag("FolderAnnotationSync").i("Skipping migration for $tag: Destination ($newId) is newer than Legacy ($legacyId). Deleting legacy.")
|
||||
legacyFile.delete()
|
||||
return
|
||||
} else {
|
||||
newFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
if (legacyFile.renameTo(newFile)) {
|
||||
Timber.tag("FolderAnnotationSync").i("Migrated $tag successfully.")
|
||||
} else {
|
||||
Timber.tag("FolderAnnotationSync").w("Failed to rename $tag file.")
|
||||
}
|
||||
} else {
|
||||
Timber.tag("FolderAnnotationSync").w("Destination file for $tag is null. Skipping.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Annotations
|
||||
safeMigrate(
|
||||
pdfAnnotationRepository.getAnnotationFileForSync(legacyId),
|
||||
pdfAnnotationRepository.getAnnotationFileForSync(newId),
|
||||
"annotations"
|
||||
)
|
||||
|
||||
// 2. Rich Text
|
||||
safeMigrate(
|
||||
pdfRichTextRepository.getFileForSync(legacyId),
|
||||
pdfRichTextRepository.getFileForSync(newId),
|
||||
"rich text"
|
||||
)
|
||||
|
||||
// 3. Layout
|
||||
safeMigrate(
|
||||
pageLayoutRepository.getLayoutFile(legacyId),
|
||||
pageLayoutRepository.getLayoutFile(newId),
|
||||
"layout"
|
||||
)
|
||||
|
||||
// 4. Text Boxes
|
||||
safeMigrate(
|
||||
pdfTextBoxRepository.getFileForSync(legacyId),
|
||||
pdfTextBoxRepository.getFileForSync(newId),
|
||||
"text boxes"
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Timber.tag("FolderAnnotationSync").e(e, "Error migrating legacy book data")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_SORT_ORDER = "sort_order"
|
||||
internal const val KEY_SHELVES = "shelf_names"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue