diff --git a/CHANGELOG.md b/CHANGELOG.md index 61fd9d3..d22f4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.2.2] - TBD + +### Fixed +- **Backward Compatibility for v1.2.0 Users (Critical)** + - App now reads BOTH old (Root) AND new (`/notes/`) folder structures + - Users upgrading from v1.2.0 no longer lose their existing notes + - Server-Restore now finds notes from v1.2.0 stored in Root folder + - Automatic deduplication prevents loading the same note twice + - Graceful error handling if Root folder is not accessible + +### Technical +- `WebDavSyncService.downloadRemoteNotes()` - Dual-mode download (Root + /notes/) +- `WebDavSyncService.restoreFromServer()` - Now uses dual-mode download +- Migration happens naturally: new uploads go to `/notes/`, old notes stay readable + +--- + ## [1.2.1] - 2026-01-05 ### Fixed diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3e01b7c..d985747 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "dev.dettmer.simplenotes" minSdk = 24 targetSdk = 36 - versionCode = 6 // πŸ› v1.2.1: Markdown Initial Export Bugfix - versionName = "1.2.1" // πŸ› v1.2.1: Markdown Initial Export Bugfix + versionCode = 7 // πŸ”§ v1.2.2: Backward compatibility for v1.2.0 migration + versionName = "1.2.2" // πŸ”§ v1.2.2: Dual-mode download (Root + /notes/) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt index c31d381..2c9b693 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt @@ -675,49 +675,144 @@ class WebDavSyncService(private val context: Context) { val conflictCount: Int ) - private fun downloadRemoteNotes(sardine: Sardine, serverUrl: String): DownloadResult { + private fun downloadRemoteNotes( + sardine: Sardine, + serverUrl: String, + includeRootFallback: Boolean = false // πŸ†• v1.2.2: Only for restore from server + ): DownloadResult { var downloadedCount = 0 var conflictCount = 0 + val processedIds = mutableSetOf() // πŸ†• v1.2.2: Track already loaded notes try { + // πŸ†• PHASE 1: Download from /notes/ (new structure v1.2.1+) val notesUrl = getNotesUrl(serverUrl) - val resources = sardine.list(notesUrl) + Logger.d(TAG, "πŸ” Phase 1: Checking /notes/ at: $notesUrl") - for (resource in resources) { - if (resource.isDirectory || !resource.name.endsWith(".json")) { - continue - } + if (sardine.exists(notesUrl)) { + Logger.d(TAG, " βœ… /notes/ exists, scanning...") + val resources = sardine.list(notesUrl) - val noteUrl = resource.href.toString() - val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } - val remoteNote = Note.fromJson(jsonContent) ?: continue - - val localNote = storage.loadNote(remoteNote.id) - - when { - localNote == null -> { - // New note from server - storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) - downloadedCount++ + for (resource in resources) { + if (resource.isDirectory || !resource.name.endsWith(".json")) { + continue } - localNote.updatedAt < remoteNote.updatedAt -> { - // Remote is newer - if (localNote.syncStatus == SyncStatus.PENDING) { - // Conflict detected - storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) - conflictCount++ - } else { - // Safe to overwrite + + // πŸ”§ Fix: Build full URL instead of using href directly + val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name + val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } + val remoteNote = Note.fromJson(jsonContent) ?: continue + + processedIds.add(remoteNote.id) // πŸ†• Mark as processed + + val localNote = storage.loadNote(remoteNote.id) + + when { + localNote == null -> { + // New note from server storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) downloadedCount++ + Logger.d(TAG, " βœ… Downloaded from /notes/: ${remoteNote.id}") + } + localNote.updatedAt < remoteNote.updatedAt -> { + // Remote is newer + if (localNote.syncStatus == SyncStatus.PENDING) { + // Conflict detected + storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) + conflictCount++ + } else { + // Safe to overwrite + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + Logger.d(TAG, " βœ… Updated from /notes/: ${remoteNote.id}") + } } } } + Logger.d(TAG, " πŸ“Š Phase 1 complete: $downloadedCount notes from /notes/") + } else { + Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1") } + + // πŸ†• PHASE 2: BACKWARD-COMPATIBILITY - Download from Root (old structure v1.2.0) + // ⚠️ ONLY for restore from server! Normal sync should NOT scan Root + if (includeRootFallback) { + val rootUrl = serverUrl.trimEnd('/') + Logger.d(TAG, "πŸ” Phase 2: Checking ROOT at: $rootUrl (Restore mode)") + + try { + val rootResources = sardine.list(rootUrl) + Logger.d(TAG, " πŸ“‚ Found ${rootResources.size} resources in ROOT") + + val oldNotes = rootResources.filter { resource -> + !resource.isDirectory && + resource.name.endsWith(".json") && + !resource.path.contains("/notes/") && // Not from /notes/ subdirectory + !resource.path.contains("/notes-md/") // Not from /notes-md/ + } + + Logger.d(TAG, " πŸ”Ž Filtered to ${oldNotes.size} .json files (excluding /notes/ and /notes-md/)") + + if (oldNotes.isNotEmpty()) { + Logger.w(TAG, "⚠️ Found ${oldNotes.size} notes in ROOT (old v1.2.0 structure)") + + for (resource in oldNotes) { + // πŸ”§ Fix: Build full URL instead of using href directly + val noteUrl = rootUrl.trimEnd('/') + "/" + resource.name + Logger.d(TAG, " πŸ“„ Processing: ${resource.name} from ${resource.path}") + + val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } + val remoteNote = Note.fromJson(jsonContent) ?: continue + + // Skip if already loaded from /notes/ + if (processedIds.contains(remoteNote.id)) { + Logger.d(TAG, " ⏭️ Skipping ${remoteNote.id} (already loaded from /notes/)") + continue + } + + processedIds.add(remoteNote.id) + val localNote = storage.loadNote(remoteNote.id) + + when { + localNote == null -> { + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + Logger.d(TAG, " βœ… Downloaded from ROOT: ${remoteNote.id}") + } + localNote.updatedAt < remoteNote.updatedAt -> { + if (localNote.syncStatus == SyncStatus.PENDING) { + storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) + conflictCount++ + } else { + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + Logger.d(TAG, " βœ… Updated from ROOT: ${remoteNote.id}") + } + } + else -> { + // Local is newer - do nothing + Logger.d(TAG, " ⏭️ Local is newer: ${remoteNote.id}") + } + } + } + Logger.d(TAG, " πŸ“Š Phase 2 complete: downloaded ${oldNotes.size} notes from ROOT") + } else { + Logger.d(TAG, " ℹ️ No old notes found in ROOT") + } + } catch (e: Exception) { + Logger.e(TAG, "⚠️ Failed to scan ROOT directory: ${e.message}", e) + Logger.e(TAG, " Stack trace: ${e.stackTraceToString()}") + // Not fatal - new users may not have root access + } + } else { + Logger.d(TAG, "⏭️ Skipping Phase 2 (Root scan) - only enabled for restore from server") + } + } catch (e: Exception) { - // Log error but don't fail entire sync + Logger.e(TAG, "❌ downloadRemoteNotes failed", e) } + Logger.d(TAG, "πŸ“Š Total download result: $downloadedCount notes, $conflictCount conflicts") return DownloadResult(downloadedCount, conflictCount) } @@ -757,38 +852,18 @@ class WebDavSyncService(private val context: Context) { Logger.d(TAG, "πŸ”„ Starting restore from server...") - val notesUrl = getNotesUrl(serverUrl) + // Clear local storage FIRST + Logger.d(TAG, "πŸ—‘οΈ Clearing local storage...") + storage.deleteAllNotes() - // List all files on server - val resources = sardine.list(notesUrl) - val jsonFiles = resources.filter { - !it.isDirectory && it.name.endsWith(".json") - } + // πŸ†• v1.2.2: Use downloadRemoteNotes() with Root fallback enabled + val result = downloadRemoteNotes( + sardine = sardine, + serverUrl = serverUrl, + includeRootFallback = true // βœ… Enable backward compatibility for restore + ) - Logger.d(TAG, "πŸ“‚ Found ${jsonFiles.size} files on server") - - val restoredNotes = mutableListOf() - - // Download and parse each file - for (resource in jsonFiles) { - try { - val fileUrl = notesUrl.trimEnd('/') + "/" + resource.name - val content = sardine.get(fileUrl).bufferedReader().use { it.readText() } - - val note = Note.fromJson(content) - if (note != null) { - restoredNotes.add(note) - Logger.d(TAG, "βœ… Downloaded: ${note.title}") - } else { - Logger.e(TAG, "❌ Failed to parse ${resource.name}: Note.fromJson returned null") - } - } catch (e: Exception) { - Logger.e(TAG, "❌ Failed to download ${resource.name}", e) - // Continue with other files - } - } - - if (restoredNotes.isEmpty()) { + if (result.downloadedCount == 0) { return@withContext RestoreResult( isSuccess = false, errorMessage = "Keine Notizen auf Server gefunden", @@ -796,22 +871,14 @@ class WebDavSyncService(private val context: Context) { ) } - // Clear local storage - Logger.d(TAG, "πŸ—‘οΈ Clearing local storage...") - storage.deleteAllNotes() + saveLastSyncTimestamp() - // Save all restored notes - Logger.d(TAG, "πŸ’Ύ Saving ${restoredNotes.size} notes...") - restoredNotes.forEach { note -> - storage.saveNote(note.copy(syncStatus = SyncStatus.SYNCED)) - } - - Logger.d(TAG, "βœ… Restore completed: ${restoredNotes.size} notes") + Logger.d(TAG, "βœ… Restore completed: ${result.downloadedCount} notes") RestoreResult( isSuccess = true, errorMessage = null, - restoredCount = restoredNotes.size + restoredCount = result.downloadedCount ) } catch (e: Exception) { diff --git a/fastlane/metadata/android/de-DE/changelogs/7.txt b/fastlane/metadata/android/de-DE/changelogs/7.txt new file mode 100644 index 0000000..5f2c138 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/7.txt @@ -0,0 +1,12 @@ +v1.2.2 - RΓΌckwΓ€rtskompatibilitΓ€t fΓΌr v1.2.0 User + +Kritische Fehlerbehebung +β€’ Server-Wiederherstellung findet jetzt ALLE Notizen (Root + /notes/) +β€’ User die von v1.2.0 upgraden verlieren keine Daten mehr +β€’ Alte Notizen aus Root-Ordner werden beim Restore gefunden + +Technische Details +β€’ Dual-Mode Download nur bei Server-Restore aktiv +β€’ Normale Syncs bleiben schnell (scannen nur /notes/) +β€’ Automatische Deduplication verhindert Duplikate +β€’ Sanfte Migration: Neue Uploads gehen in /notes/, alte bleiben lesbar diff --git a/fastlane/metadata/android/en-US/changelogs/7.txt b/fastlane/metadata/android/en-US/changelogs/7.txt new file mode 100644 index 0000000..6122729 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/7.txt @@ -0,0 +1,12 @@ +v1.2.2 - Backward Compatibility for v1.2.0 Users + +Critical Bugfix +β€’ Server restore now finds ALL notes (Root + /notes/) +β€’ Users upgrading from v1.2.0 no longer lose data +β€’ Old notes from Root folder are found during restore + +Technical Details +β€’ Dual-mode download only active for server restore +β€’ Normal syncs remain fast (scan only /notes/) +β€’ Automatic deduplication prevents duplicates +β€’ Smooth migration: New uploads go to /notes/, old ones remain readable