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:
Aryan 2026-03-05 16:34:22 +05:30 committed by GitHub
parent 0b7bb53a28
commit 7e28c1c2b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 464 additions and 57 deletions

View file

@ -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"