From 63af7d30dcc56c82fd6dfd5d55f57f180bbddba4 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Wed, 7 Jan 2026 12:27:27 +0100 Subject: [PATCH] Release v1.3.0: Multi-Device Sync with Deletion Tracking New Features: - Multi-Device Sync with deletion tracking (prevents zombie notes) - Server deletion via swipe gesture with confirmation dialog - E-Tag performance optimization (~150ms vs 3s for no-change syncs) - Markdown Auto-Sync toggle (unified Export + Auto-Import) - Manual Markdown sync button for performance control - Server-Restore modes (Merge/Replace/Overwrite) Technical Implementation: - DeletionTracker model with JSON persistence - Intelligent server checks with E-Tag caching - Deletion-aware download logic - Two-stage swipe deletion with Material Design dialog - Automatic Markdown import during sync - YAML frontmatter scanning for robust file deletion Thanks to Thomas from Bielefeld for reporting the multi-device sync issue! Compatible with: v1.2.0-v1.3.0 --- CHANGELOG.md | 55 +- README.en.md | 2 + README.md | 2 + android/app/build.gradle.kts | 4 +- .../dev/dettmer/simplenotes/MainActivity.kt | 142 +++- .../dettmer/simplenotes/SettingsActivity.kt | 199 ++--- .../simplenotes/models/DeletionTracker.kt | 77 ++ .../simplenotes/storage/NotesStorage.kt | 85 ++- .../simplenotes/sync/WebDavSyncService.kt | 693 +++++++++++++++++- .../dettmer/simplenotes/utils/Constants.kt | 4 + .../src/main/res/layout/activity_settings.xml | 45 +- .../res/layout/dialog_server_deletion.xml | 15 + .../metadata/android/de-DE/changelogs/8.txt | 12 + .../metadata/android/en-US/changelogs/8.txt | 12 + 14 files changed, 1177 insertions(+), 170 deletions(-) create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt create mode 100644 android/app/src/main/res/layout/dialog_server_deletion.xml create mode 100644 fastlane/metadata/android/de-DE/changelogs/8.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/8.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index d22f4a5..2b31b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- -## [1.2.2] - TBD +## [1.3.0] - 2026-01-07 + +### Added +- **πŸš€ Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!) + - Automatic download of new notes from other devices + - Deletion tracking prevents "zombie notes" (deleted notes don't come back) + - Smart cleanup: Re-created notes (newer timestamp) are downloaded + - Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0 + +- **πŸ—‘οΈ Server Deletion via Swipe Gesture** + - Swipe left on notes to delete from server (requires confirmation) + - Prevents duplicate notes on other devices + - Works with deletion tracking system + - Material Design confirmation dialog + +- **⚑ E-Tag Performance Optimization** + - Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes") + - 20x faster when server has no updates + - E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable) + - Battery-friendly with minimal server requests + +- **πŸ“₯ Markdown Auto-Sync Toggle** + - NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles) + - When enabled: Notes export to Markdown AND import changes automatically + - When disabled: Manual sync button appears for on-demand synchronization + - Performance: Auto-Sync OFF = 0ms overhead + +- **πŸ”˜ Manual Markdown Sync Button** + - Manual sync button for performance-conscious users + - Shows import/export counts after completion + - Only visible when Auto-Sync is disabled + - On-demand synchronization (~150-200ms only when triggered) + +- **βš™οΈ Server-Restore Modes** + - MERGE: Keep local notes + add server notes + - REPLACE: Delete all local + download from server + - OVERWRITE: Update duplicates, keep non-duplicates + - Restore modes now work correctly for WebDAV restore + +### Technical +- New `DeletionTracker` model with JSON persistence +- `NotesStorage`: Added deletion tracking methods +- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching +- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads +- `WebDavSyncService.restoreFromServer()`: Support for restore modes +- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning +- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync +- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts +- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation +- E-Tag caching in SharedPreferences for performance + +--- + +## [1.2.2] - 2026-01-06 ### Fixed - **Backward Compatibility for v1.2.0 Users (Critical)** diff --git a/README.en.md b/README.en.md index c0e5165..7e7c69d 100644 --- a/README.en.md +++ b/README.en.md @@ -6,6 +6,8 @@ [![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes) + **πŸ“± [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** Β· **πŸ“– [Documentation](docs/DOCS.en.md)** Β· **πŸš€ [Quick Start](QUICKSTART.en.md)** **🌍 Languages:** [Deutsch](README.md) Β· **English** diff --git a/README.md b/README.md index 7c3f6e6..b974144 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ [![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[Get it on IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes) + **πŸ“± [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** Β· **πŸ“– [Dokumentation](docs/DOCS.md)** Β· **πŸš€ [Quick Start](QUICKSTART.md)** **🌍 Sprachen:** **Deutsch** Β· [English](README.en.md) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d985747..16d4b16 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 = 7 // πŸ”§ v1.2.2: Backward compatibility for v1.2.0 migration - versionName = "1.2.2" // πŸ”§ v1.2.2: Dual-mode download (Root + /notes/) + versionCode = 8 // πŸš€ v1.3.0: Multi-Device Sync with deletion tracking + versionName = "1.3.0" // πŸš€ v1.3.0: Multi-Device Sync, E-Tag caching, Markdown auto-import testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt index 752708d..5c54f85 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt @@ -21,13 +21,19 @@ import com.google.android.material.color.DynamicColors import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import com.google.android.material.card.MaterialCardView +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dev.dettmer.simplenotes.adapters.NotesAdapter +import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.sync.SyncWorker import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.Constants import android.widget.TextView +import android.widget.CheckBox +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers @@ -320,36 +326,20 @@ class MainActivity : AppCompatActivity() { ): Boolean = false override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val position = viewHolder.adapterPosition - val note = adapter.currentList[position] - val notesCopy = adapter.currentList.toMutableList() + val position = viewHolder.bindingAdapterPosition + val swipedNote = adapter.currentList[position] - // Track pending deletion to prevent flicker - pendingDeletions.add(note.id) + // Store original list BEFORE removing note + val originalList = adapter.currentList.toList() - // Remove from list immediately for visual feedback - notesCopy.removeAt(position) - adapter.submitList(notesCopy) + // Remove from list for visual feedback (NOT from storage yet!) + val listWithoutNote = originalList.toMutableList().apply { + removeAt(position) + } + adapter.submitList(listWithoutNote) - // Show Snackbar with UNDO - Snackbar.make( - recyclerViewNotes, - "Notiz gelΓΆscht", - Snackbar.LENGTH_LONG - ).setAction("RÜCKGΓ„NGIG") { - // UNDO: Remove from pending deletions and restore - pendingDeletions.remove(note.id) - loadNotes() - }.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - if (event != DISMISS_EVENT_ACTION) { - // Snackbar dismissed without UNDO β†’ Actually delete the note - storage.deleteNote(note.id) - pendingDeletions.remove(note.id) - loadNotes() - } - } - }).show() + // Show dialog with ability to restore + showServerDeletionDialog(swipedNote, originalList) } override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { @@ -361,6 +351,104 @@ class MainActivity : AppCompatActivity() { itemTouchHelper.attachToRecyclerView(recyclerViewNotes) } + private fun showServerDeletionDialog(note: Note, originalList: List) { + val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false) + + if (alwaysDeleteFromServer) { + // Auto-delete from server without asking + deleteNoteLocally(note, deleteFromServer = true) + return + } + + val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null) + val checkboxAlways = dialogView.findViewById(R.id.checkboxAlwaysDeleteFromServer) + + MaterialAlertDialogBuilder(this) + .setTitle("Notiz lΓΆschen") + .setMessage("\"${note.title}\" wird lokal gelΓΆscht.\n\nAuch vom Server lΓΆschen?") + .setView(dialogView) + .setNeutralButton("Abbrechen") { _, _ -> + // RESTORE: Re-submit original list (note is NOT deleted from storage) + adapter.submitList(originalList) + } + .setOnCancelListener { + // User pressed back - also restore + adapter.submitList(originalList) + } + .setPositiveButton("Nur lokal") { _, _ -> + if (checkboxAlways.isChecked) { + prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply() + } + // NOW actually delete from storage + deleteNoteLocally(note, deleteFromServer = false) + } + .setNegativeButton("Vom Server lΓΆschen") { _, _ -> + if (checkboxAlways.isChecked) { + prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply() + } + deleteNoteLocally(note, deleteFromServer = true) + } + .setCancelable(true) + .show() + } + + private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) { + // Track pending deletion to prevent flicker + pendingDeletions.add(note.id) + + // Delete from storage + storage.deleteNote(note.id) + + // Reload to reflect changes + loadNotes() + + // Show Snackbar with UNDO option + val message = if (deleteFromServer) { + "\"${note.title}\" wird lokal und vom Server gelΓΆscht" + } else { + "\"${note.title}\" lokal gelΓΆscht (Server bleibt)" + } + + Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG) + .setAction("RÜCKGΓ„NGIG") { + // UNDO: Restore note + storage.saveNote(note) + pendingDeletions.remove(note.id) + loadNotes() + } + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + if (event != DISMISS_EVENT_ACTION) { + // Snackbar dismissed without UNDO + pendingDeletions.remove(note.id) + + // Delete from server if requested + if (deleteFromServer) { + lifecycleScope.launch { + try { + val webdavService = WebDavSyncService(this@MainActivity) + val success = webdavService.deleteNoteFromServer(note.id) + if (success) { + runOnUiThread { + Toast.makeText(this@MainActivity, "Vom Server gelΓΆscht", Toast.LENGTH_SHORT).show() + } + } else { + runOnUiThread { + Toast.makeText(this@MainActivity, "Server-LΓΆschung fehlgeschlagen", Toast.LENGTH_LONG).show() + } + } + } catch (e: Exception) { + runOnUiThread { + Toast.makeText(this@MainActivity, "Server-Fehler: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + } + } + } + }).show() + } + private fun setupFab() { fabAddNote.setOnClickListener { openNoteEditor(null) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt index ef3ca23..fc1d449 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt @@ -9,6 +9,7 @@ import android.os.PowerManager import android.provider.Settings import android.util.Log import android.view.MenuItem +import android.view.View import android.widget.Button import android.widget.EditText import android.widget.RadioButton @@ -57,14 +58,15 @@ class SettingsActivity : AppCompatActivity() { private lateinit var editTextUsername: EditText private lateinit var editTextPassword: EditText private lateinit var switchAutoSync: SwitchCompat - private lateinit var switchMarkdownExport: SwitchCompat + private lateinit var switchMarkdownAutoSync: SwitchCompat private lateinit var buttonTestConnection: Button private lateinit var buttonSyncNow: Button private lateinit var buttonCreateBackup: Button private lateinit var buttonRestoreFromFile: Button private lateinit var buttonRestoreFromServer: Button - private lateinit var buttonImportMarkdown: Button + private lateinit var buttonManualMarkdownSync: Button private lateinit var textViewServerStatus: TextView + private lateinit var textViewManualSyncInfo: TextView // Protocol Selection UI private lateinit var protocolRadioGroup: RadioGroup @@ -130,14 +132,15 @@ class SettingsActivity : AppCompatActivity() { editTextUsername = findViewById(R.id.editTextUsername) editTextPassword = findViewById(R.id.editTextPassword) switchAutoSync = findViewById(R.id.switchAutoSync) - switchMarkdownExport = findViewById(R.id.switchMarkdownExport) + switchMarkdownAutoSync = findViewById(R.id.switchMarkdownAutoSync) buttonTestConnection = findViewById(R.id.buttonTestConnection) buttonSyncNow = findViewById(R.id.buttonSyncNow) buttonCreateBackup = findViewById(R.id.buttonCreateBackup) buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile) buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer) - buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown) + buttonManualMarkdownSync = findViewById(R.id.buttonManualMarkdownSync) textViewServerStatus = findViewById(R.id.textViewServerStatus) + textViewManualSyncInfo = findViewById(R.id.textViewManualSyncInfo) // Protocol Selection UI protocolRadioGroup = findViewById(R.id.protocolRadioGroup) @@ -180,7 +183,14 @@ class SettingsActivity : AppCompatActivity() { editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, "")) editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, "")) switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) - switchMarkdownExport.isChecked = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) // Default: disabled (offline-first) + + // Load Markdown Auto-Sync (backward compatible) + val markdownExport = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) + val markdownAutoImport = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false) + val markdownAutoSync = markdownExport && markdownAutoImport + switchMarkdownAutoSync.isChecked = markdownAutoSync + + updateMarkdownButtonVisibility() // Update hint text based on selected protocol updateProtocolHint() @@ -269,17 +279,16 @@ class SettingsActivity : AppCompatActivity() { showRestoreDialog(RestoreSource.WEBDAV_SERVER, null) } - buttonImportMarkdown.setOnClickListener { - saveSettings() - importMarkdownChanges() + buttonManualMarkdownSync.setOnClickListener { + performManualMarkdownSync() } switchAutoSync.setOnCheckedChangeListener { _, isChecked -> onAutoSyncToggled(isChecked) } - switchMarkdownExport.setOnCheckedChangeListener { _, isChecked -> - onMarkdownExportToggled(isChecked) + switchMarkdownAutoSync.setOnCheckedChangeListener { _, isChecked -> + onMarkdownAutoSyncToggled(isChecked) } // Clear error when user starts typing again @@ -548,7 +557,7 @@ class SettingsActivity : AppCompatActivity() { } } - private fun onMarkdownExportToggled(enabled: Boolean) { + private fun onMarkdownAutoSyncToggled(enabled: Boolean) { if (enabled) { // Initial-Export wenn Feature aktiviert wird lifecycleScope.launch { @@ -559,7 +568,7 @@ class SettingsActivity : AppCompatActivity() { if (currentNoteCount > 0) { // Zeige Progress-Dialog val progressDialog = ProgressDialog(this@SettingsActivity).apply { - setTitle("Markdown-Export") + setTitle("Markdown Auto-Sync") setMessage("Exportiere Notizen nach Markdown...") setProgressStyle(ProgressDialog.STYLE_HORIZONTAL) max = currentNoteCount @@ -577,7 +586,7 @@ class SettingsActivity : AppCompatActivity() { if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { progressDialog.dismiss() showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren") - switchMarkdownExport.isChecked = false + switchMarkdownAutoSync.isChecked = false return@launch } @@ -597,8 +606,13 @@ class SettingsActivity : AppCompatActivity() { progressDialog.dismiss() - // Speichere Einstellung - prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply() + // Speichere beide Einstellungen + prefs.edit() + .putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled) + .putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled) + .apply() + + updateMarkdownButtonVisibility() // Erfolgs-Nachricht showToast("βœ… $exportedCount Notizen nach Markdown exportiert") @@ -608,76 +622,35 @@ class SettingsActivity : AppCompatActivity() { showToast("❌ Export fehlgeschlagen: ${e.message}") // Deaktiviere Toggle bei Fehler - switchMarkdownExport.isChecked = false + switchMarkdownAutoSync.isChecked = false return@launch } } else { - // Keine Notizen vorhanden - speichere Einstellung direkt - prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply() - showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert") + // Keine Notizen vorhanden - speichere Einstellungen direkt + prefs.edit() + .putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled) + .putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled) + .apply() + + updateMarkdownButtonVisibility() + showToast("Markdown Auto-Sync aktiviert - Notizen werden als .md-Dateien exportiert und importiert") } } catch (e: Exception) { - Logger.e(TAG, "Error toggling markdown export: ${e.message}") + Logger.e(TAG, "Error toggling markdown auto-sync: ${e.message}") showToast("Fehler: ${e.message}") - switchMarkdownExport.isChecked = false + switchMarkdownAutoSync.isChecked = false } } } else { - // Deaktivieren - nur Setting speichern - prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply() - showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv") - } - } - - private fun importMarkdownChanges() { - // PrΓΌfen ob Server konfiguriert ist - val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: "" - val username = prefs.getString(Constants.KEY_USERNAME, "") ?: "" - val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: "" - - if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { - showToast("Bitte zuerst WebDAV-Server konfigurieren") - return - } - - // Import-Dialog mit Warnung - AlertDialog.Builder(this) - .setTitle("Markdown-Import") - .setMessage( - "Importiert Γ„nderungen aus .md-Dateien vom Server.\n\n" + - "⚠️ Bei Konflikten: Last-Write-Wins (neuere Zeitstempel gewinnen)\n\n" + - "Fortfahren?" - ) - .setPositiveButton("Importieren") { _, _ -> - performMarkdownImport(serverUrl, username, password) - } - .setNegativeButton("Abbrechen", null) - .show() - } - - private fun performMarkdownImport(serverUrl: String, username: String, password: String) { - showToast("Importiere Markdown-Dateien...") - - lifecycleScope.launch(Dispatchers.IO) { - try { - val syncService = WebDavSyncService(this@SettingsActivity) - val importCount = syncService.syncMarkdownFiles(serverUrl, username, password) - - withContext(Dispatchers.Main) { - if (importCount > 0) { - showToast("$importCount Notizen aus Markdown importiert") - // Benachrichtige MainActivity zum Neuladen - sendBroadcast(Intent("dev.dettmer.simplenotes.NOTES_CHANGED")) - } else { - showToast("Keine Markdown-Γ„nderungen gefunden") - } - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - showToast("Import-Fehler: ${e.message}") - } - } + // Deaktivieren - Settings speichern + prefs.edit() + .putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled) + .putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled) + .apply() + + updateMarkdownButtonVisibility() + showToast("Markdown Auto-Sync deaktiviert - nur JSON-Sync aktiv") } } @@ -940,7 +913,7 @@ class SettingsActivity : AppCompatActivity() { // Refresh MainActivity's note list setResult(RESULT_OK) - broadcastNotesChanged() + broadcastNotesChanged(result.imported_notes) } else { showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler") } @@ -953,12 +926,7 @@ class SettingsActivity : AppCompatActivity() { } /** - * FΓΌhrt Restore vom Server durch (Task #1.2.0-05b) - * Nutzt neues universelles Dialog-System mit Restore-Modi - * - * HINWEIS: Die alte WebDavSyncService.restoreFromServer() Funktion - * unterstΓΌtzt noch keine Restore-Modi. Aktuell wird immer REPLACE verwendet. - * TODO: WebDavSyncService.restoreFromServer() erweitern fΓΌr v1.2.1+ + * Server-Restore mit Restore-Modi (v1.3.0) */ private fun performRestoreFromServer(mode: RestoreMode) { lifecycleScope.launch { @@ -970,7 +938,6 @@ class SettingsActivity : AppCompatActivity() { try { Logger.d(TAG, "πŸ“₯ Restoring from server (mode: $mode)") - Logger.w(TAG, "⚠️ Server-Restore nutzt aktuell immer REPLACE Mode (TODO: v1.2.1+)") // Auto-Backup erstellen (Sicherheitsnetz) val autoBackupUri = backupManager.createAutoBackup() @@ -981,8 +948,7 @@ class SettingsActivity : AppCompatActivity() { // Server-Restore durchfΓΌhren val webdavService = WebDavSyncService(this@SettingsActivity) val result = withContext(Dispatchers.IO) { - // Nutzt alte Funktion (immer REPLACE) - webdavService.restoreFromServer() + webdavService.restoreFromServer(mode) // βœ… Pass mode parameter } progressDialog.dismiss() @@ -990,7 +956,7 @@ class SettingsActivity : AppCompatActivity() { if (result.isSuccess) { showToast("βœ… Wiederhergestellt: ${result.restoredCount} Notizen") setResult(RESULT_OK) - broadcastNotesChanged() + broadcastNotesChanged(result.restoredCount) } else { showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler") } @@ -1005,13 +971,70 @@ class SettingsActivity : AppCompatActivity() { /** * Sendet Broadcast dass Notizen geΓ€ndert wurden */ - private fun broadcastNotesChanged() { + private fun broadcastNotesChanged(count: Int = 0) { val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED) intent.putExtra("success", true) - intent.putExtra("syncedCount", 0) + intent.putExtra("syncedCount", count) LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } + /** + * Updates visibility of manual sync button based on Auto-Sync toggle state + */ + private fun updateMarkdownButtonVisibility() { + val autoSyncEnabled = switchMarkdownAutoSync.isChecked + val visibility = if (autoSyncEnabled) View.GONE else View.VISIBLE + + textViewManualSyncInfo.visibility = visibility + buttonManualMarkdownSync.visibility = visibility + } + + /** + * Performs manual Markdown sync (Export + Import) + * Called when manual sync button is clicked + */ + private fun performManualMarkdownSync() { + lifecycleScope.launch { + var progressDialog: ProgressDialog? = null + try { + // Validierung + val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") + val username = prefs.getString(Constants.KEY_USERNAME, "") + val password = prefs.getString(Constants.KEY_PASSWORD, "") + + if (serverUrl.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) { + showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren") + return@launch + } + + // Progress-Dialog + progressDialog = ProgressDialog(this@SettingsActivity).apply { + setTitle("Markdown-Sync") + setMessage("Synchronisiere Markdown-Dateien...") + setCancelable(false) + show() + } + + // Sync ausfΓΌhren + val syncService = dev.dettmer.simplenotes.sync.WebDavSyncService(this@SettingsActivity) + val result = syncService.manualMarkdownSync() + + progressDialog.dismiss() + + // Erfolgs-Nachricht + val message = "βœ… Sync abgeschlossen\nπŸ“€ ${result.exportedCount} exportiert\nπŸ“₯ ${result.importedCount} importiert" + showToast(message) + + Logger.d("SettingsActivity", "Manual markdown sync: exported=${result.exportedCount}, imported=${result.importedCount}") + + } catch (e: Exception) { + progressDialog?.dismiss() + showToast("❌ Sync fehlgeschlagen: ${e.message}") + Logger.e("SettingsActivity", "Manual markdown sync failed", e) + } + } + } + /** * Zeigt Error-Dialog an */ diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt new file mode 100644 index 0000000..d120fce --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt @@ -0,0 +1,77 @@ +package dev.dettmer.simplenotes.models + +import org.json.JSONArray +import org.json.JSONObject + +data class DeletionRecord( + val id: String, + val deletedAt: Long, + val deviceId: String +) + +data class DeletionTracker( + val version: Int = 1, + val deletedNotes: MutableList = mutableListOf() +) { + fun addDeletion(noteId: String, deviceId: String) { + if (!deletedNotes.any { it.id == noteId }) { + deletedNotes.add(DeletionRecord(noteId, System.currentTimeMillis(), deviceId)) + } + } + + fun isDeleted(noteId: String): Boolean { + return deletedNotes.any { it.id == noteId } + } + + fun getDeletionTimestamp(noteId: String): Long? { + return deletedNotes.find { it.id == noteId }?.deletedAt + } + + fun removeDeletion(noteId: String) { + deletedNotes.removeIf { it.id == noteId } + } + + fun toJson(): String { + val jsonObject = JSONObject() + jsonObject.put("version", version) + + val notesArray = JSONArray() + for (record in deletedNotes) { + val recordObj = JSONObject() + recordObj.put("id", record.id) + recordObj.put("deletedAt", record.deletedAt) + recordObj.put("deviceId", record.deviceId) + notesArray.put(recordObj) + } + jsonObject.put("deletedNotes", notesArray) + + return jsonObject.toString(2) // Pretty print with 2-space indent + } + + companion object { + fun fromJson(json: String): DeletionTracker? { + return try { + val jsonObject = JSONObject(json) + val version = jsonObject.optInt("version", 1) + val deletedNotes = mutableListOf() + + val notesArray = jsonObject.optJSONArray("deletedNotes") + if (notesArray != null) { + for (i in 0 until notesArray.length()) { + val recordObj = notesArray.getJSONObject(i) + val record = DeletionRecord( + id = recordObj.getString("id"), + deletedAt = recordObj.getLong("deletedAt"), + deviceId = recordObj.getString("deviceId") + ) + deletedNotes.add(record) + } + } + + DeletionTracker(version, deletedNotes) + } catch (e: Exception) { + null + } + } + } +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt index 2797a1c..a439735 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt @@ -1,11 +1,18 @@ package dev.dettmer.simplenotes.storage import android.content.Context +import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.Note +import dev.dettmer.simplenotes.utils.DeviceIdGenerator +import dev.dettmer.simplenotes.utils.Logger import java.io.File class NotesStorage(private val context: Context) { + companion object { + private const val TAG = "NotesStorage" + } + private val notesDir: File = File(context.filesDir, "notes").apply { if (!exists()) mkdirs() } @@ -34,19 +41,89 @@ class NotesStorage(private val context: Context) { fun deleteNote(id: String): Boolean { val file = File(notesDir, "$id.json") - return file.delete() + val deleted = file.delete() + + if (deleted) { + Logger.d(TAG, "πŸ—‘οΈ Deleted note: $id") + + // Track deletion to prevent zombie notes + val deviceId = DeviceIdGenerator.getDeviceId(context) + trackDeletion(id, deviceId) + } + + return deleted } fun deleteAllNotes(): Boolean { return try { - notesDir.listFiles() - ?.filter { it.extension == "json" } - ?.forEach { it.delete() } + val notes = loadAllNotes() + val deviceId = DeviceIdGenerator.getDeviceId(context) + + for (note in notes) { + deleteNote(note.id) // Uses trackDeletion() automatically + } + + Logger.d(TAG, "πŸ—‘οΈ Deleted all notes (${notes.size} notes)") true } catch (e: Exception) { + Logger.e(TAG, "Failed to delete all notes", e) false } } + // === Deletion Tracking === + + private fun getDeletionTrackerFile(): File { + return File(context.filesDir, "deleted_notes.json") + } + + fun loadDeletionTracker(): DeletionTracker { + val file = getDeletionTrackerFile() + if (!file.exists()) { + return DeletionTracker() + } + + return try { + val json = file.readText() + DeletionTracker.fromJson(json) ?: DeletionTracker() + } catch (e: Exception) { + Logger.e(TAG, "Failed to load deletion tracker", e) + DeletionTracker() + } + } + + fun saveDeletionTracker(tracker: DeletionTracker) { + try { + val file = getDeletionTrackerFile() + file.writeText(tracker.toJson()) + + if (tracker.deletedNotes.size > 1000) { + Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries") + } + + Logger.d(TAG, "βœ… Deletion tracker saved (${tracker.deletedNotes.size} entries)") + } catch (e: Exception) { + Logger.e(TAG, "Failed to save deletion tracker", e) + } + } + + fun trackDeletion(noteId: String, deviceId: String) { + val tracker = loadDeletionTracker() + tracker.addDeletion(noteId, deviceId) + saveDeletionTracker(tracker) + Logger.d(TAG, "πŸ“ Tracked deletion: $noteId") + } + + fun isNoteDeleted(noteId: String): Boolean { + val tracker = loadDeletionTracker() + return tracker.isDeleted(noteId) + } + + fun clearDeletionTracker() { + saveDeletionTracker(DeletionTracker()) + Logger.d(TAG, "πŸ—‘οΈ Deletion tracker cleared") + } + + fun getNotesDir(): File = notesDir } 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 2c9b693..89dd99d 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 @@ -6,6 +6,7 @@ import android.net.NetworkCapabilities import com.thegrizzlylabs.sardineandroid.Sardine import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import dev.dettmer.simplenotes.BuildConfig +import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.storage.NotesStorage @@ -21,8 +22,17 @@ import java.net.NetworkInterface import java.net.Proxy import java.net.Socket import java.net.URL +import java.util.Date import javax.net.SocketFactory +/** + * Result of manual Markdown sync operation + */ +data class ManualMarkdownSyncResult( + val exportedCount: Int, + val importedCount: Int +) + class WebDavSyncService(private val context: Context) { companion object { @@ -256,6 +266,131 @@ class WebDavSyncService(private val context: Context) { } } + /** + * Checks if server has changes using E-Tag caching + * + * v1.3.0: Also checks /notes-md/ if Markdown Auto-Import enabled + * + * Performance: ~100-200ms (E-Tag cache hit) + * ~300-500ms (E-Tag miss, needs PROPFIND) + * + * Strategy: + * 1. Store E-Tag of /notes/ collection after each sync + * 2. HEAD request to check if E-Tag changed + * 3. If changed β†’ server has updates + * 4. If unchanged β†’ skip sync + */ + private suspend fun checkServerForChanges(sardine: Sardine, serverUrl: String): Boolean { + return try { + val startTime = System.currentTimeMillis() + val lastSyncTime = getLastSyncTimestamp() + + if (lastSyncTime == 0L) { + Logger.d(TAG, "πŸ“ Never synced - assuming server has changes") + return true + } + + val notesUrl = getNotesUrl(serverUrl) + if (!sardine.exists(notesUrl)) { + Logger.d(TAG, "πŸ“ /notes/ doesn't exist - no server changes") + return false + } + + // ====== JSON FILES CHECK (/notes/) ====== + + // Optimierung 1: E-Tag Check (fastest - ~100ms) + val cachedETag = prefs.getString("notes_collection_etag", null) + var jsonHasChanges = false + + if (cachedETag != null) { + try { + val resources = sardine.list(notesUrl, 0) // Depth 0 = only collection itself + val currentETag = resources.firstOrNull()?.contentLength?.toString() ?: "" + + if (currentETag == cachedETag) { + val elapsed = System.currentTimeMillis() - startTime + Logger.d(TAG, "⚑ E-Tag match - no JSON changes (${elapsed}ms)") + // Don't return yet - check Markdown too! + } else { + Logger.d(TAG, "πŸ”„ E-Tag changed - JSON files have updates") + return true // Early return if JSON changed + } + } catch (e: Exception) { + Logger.w(TAG, "E-Tag check failed: ${e.message}, falling back to timestamp check") + jsonHasChanges = true + } + } else { + jsonHasChanges = true + } + + // Optimierung 2: Smart Timestamp Check for JSON (medium - ~300ms) + if (jsonHasChanges || cachedETag == null) { + val resources = sardine.list(notesUrl, 1) // Depth 1 = collection + children + + val jsonHasNewer = resources.any { resource -> + !resource.isDirectory && + resource.name.endsWith(".json") && + resource.modified?.time?.let { it > lastSyncTime } ?: false + } + + if (jsonHasNewer) { + val elapsed = System.currentTimeMillis() - startTime + Logger.d(TAG, "πŸ” JSON check: hasNewer=true (${resources.size} resources, ${elapsed}ms)") + return true + } + } + + // ====== MARKDOWN FILES CHECK (/notes-md/) ====== + // IMPORTANT: E-Tag for collections does NOT work for content changes! + // β†’ Use hybrid approach: If-Modified-Since + Timestamp fallback + + val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false) + if (!markdownAutoImportEnabled) { + Logger.d(TAG, "⏭️ Markdown check skipped (auto-import disabled)") + } else { + val mdUrl = getMarkdownUrl(serverUrl) + + if (!sardine.exists(mdUrl)) { + Logger.d(TAG, "πŸ“ /notes-md/ doesn't exist - no markdown changes") + } else { + Logger.d(TAG, "πŸ“ Checking Markdown files (hybrid approach)...") + + // Strategy: Timestamp-based check (reliable, always works) + // Note: If-Modified-Since support varies by WebDAV server + // We use timestamp comparison which is universal + val mdResources = sardine.list(mdUrl, 1) + val mdHasNewer = mdResources.any { resource -> + !resource.isDirectory && + resource.name.endsWith(".md") && + resource.modified?.time?.let { + val hasNewer = it > lastSyncTime + if (hasNewer) { + Logger.d(TAG, " πŸ“„ ${resource.name}: modified=${resource.modified}, lastSync=$lastSyncTime") + } + hasNewer + } ?: false + } + + if (mdHasNewer) { + val mdCount = mdResources.count { !it.isDirectory && it.name.endsWith(".md") } + Logger.d(TAG, "πŸ“ Markdown files have changes ($mdCount files checked)") + return true + } else { + Logger.d(TAG, "βœ… Markdown files up-to-date (timestamp check)") + } + } + } + + val elapsed = System.currentTimeMillis() - startTime + Logger.d(TAG, "βœ… No changes detected (JSON + Markdown checked, ${elapsed}ms)") + return false + + } catch (e: Exception) { + Logger.w(TAG, "Server check failed: ${e.message} - assuming changes exist") + true // Safe default: check anyway + } + } + /** * PrΓΌft ob lokale Γ„nderungen seit letztem Sync vorhanden sind (v1.1.2) * Performance-Optimierung: Vermeidet unnΓΆtige Sync-Operationen @@ -266,31 +401,50 @@ class WebDavSyncService(private val context: Context) { return@withContext try { val lastSyncTime = getLastSyncTimestamp() - // Wenn noch nie gesynct, dann haben wir Γ„nderungen + // Check 1: Never synced if (lastSyncTime == 0L) { - Logger.d(TAG, "πŸ“ Never synced - assuming changes exist") + Logger.d(TAG, "πŸ“ Never synced - has changes: true") return@withContext true } - // PrΓΌfe ob Notizen existieren die neuer sind als letzter Sync - val storage = dev.dettmer.simplenotes.storage.NotesStorage(context) + // Check 2: Local changes + val storage = NotesStorage(context) val allNotes = storage.loadAllNotes() - - val hasChanges = allNotes.any { note -> + val hasLocalChanges = allNotes.any { note -> note.updatedAt > lastSyncTime } - Logger.d(TAG, "πŸ“Š Unsynced changes check: $hasChanges (${allNotes.size} notes total)") - if (hasChanges) { - val unsyncedCount = allNotes.count { note -> note.updatedAt > lastSyncTime } - Logger.d(TAG, " β†’ $unsyncedCount notes modified since last sync") + if (hasLocalChanges) { + val unsyncedCount = allNotes.count { it.updatedAt > lastSyncTime } + Logger.d(TAG, "πŸ“ Local changes: $unsyncedCount notes modified") + return@withContext true } - hasChanges + // Check 3: Server changes (respects user preference) + val alwaysCheckServer = prefs.getBoolean(Constants.KEY_ALWAYS_CHECK_SERVER, true) + + if (!alwaysCheckServer) { + Logger.d(TAG, "⏭️ Server check disabled by user - has changes: false") + return@withContext false + } + + // Perform intelligent server check + val sardine = getSardine() + val serverUrl = getServerUrl() + + if (sardine == null || serverUrl == null) { + Logger.w(TAG, "⚠️ Cannot check server - no credentials") + return@withContext false + } + + val hasServerChanges = checkServerForChanges(sardine, serverUrl) + Logger.d(TAG, "πŸ“Š Final check: local=$hasLocalChanges, server=$hasServerChanges") + + hasServerChanges + } catch (e: Exception) { - Logger.e(TAG, "Failed to check for unsynced changes - assuming changes exist", e) - // Bei Fehler lieber sync durchfΓΌhren (safe default) - true + Logger.e(TAG, "Failed to check for unsynced changes", e) + true // Safe default } } @@ -452,7 +606,11 @@ class WebDavSyncService(private val context: Context) { // Download remote notes try { Logger.d(TAG, "⬇️ Downloading remote notes...") - val downloadResult = downloadRemoteNotes(sardine, serverUrl) + val downloadResult = downloadRemoteNotes( + sardine, + serverUrl, + includeRootFallback = true // βœ… v1.3.0: Enable for v1.2.0 compatibility + ) syncedCount += downloadResult.downloadedCount conflictCount += downloadResult.conflictCount Logger.d(TAG, "βœ… Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}") @@ -462,7 +620,24 @@ class WebDavSyncService(private val context: Context) { throw e } - Logger.d(TAG, "πŸ“ Step 6: Saving sync timestamp") + Logger.d(TAG, "πŸ“ Step 6: Auto-import Markdown (if enabled)") + // Auto-import Markdown files from server + var markdownImportedCount = 0 + try { + val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false) + if (markdownAutoImportEnabled) { + Logger.d(TAG, "πŸ“₯ Auto-importing Markdown files...") + markdownImportedCount = importMarkdownFiles(sardine, serverUrl) + Logger.d(TAG, "βœ… Auto-imported: $markdownImportedCount Markdown files") + } else { + Logger.d(TAG, "⏭️ Markdown auto-import disabled") + } + } catch (e: Exception) { + Logger.e(TAG, "⚠️ Markdown auto-import failed (non-fatal)", e) + // Non-fatal, continue + } + + Logger.d(TAG, "πŸ“ Step 7: Saving sync timestamp") // Update last sync timestamp try { saveLastSyncTimestamp() @@ -473,12 +648,24 @@ class WebDavSyncService(private val context: Context) { // Non-fatal, continue } - Logger.d(TAG, "πŸŽ‰ Sync completed successfully - Total synced: $syncedCount") + // βœ… v1.3.0: Hybrid counting to prevent double-counting + // - If JSON sync occurred, it represents unique notes (JSON is source of truth) + // - If ONLY Markdown edits (no JSON), use Markdown count + val effectiveSyncedCount = if (syncedCount > 0) { + syncedCount // JSON-based count is authoritative + } else { + markdownImportedCount // Fallback: Markdown-only edits + } + + Logger.d(TAG, "πŸŽ‰ Sync completed successfully: $effectiveSyncedCount notes") + if (markdownImportedCount > 0 && syncedCount > 0) { + Logger.d(TAG, "πŸ“ Including $markdownImportedCount Markdown file updates") + } Logger.d(TAG, "═══════════════════════════════════════") SyncResult( isSuccess = true, - syncedCount = syncedCount, + syncedCount = effectiveSyncedCount, conflictCount = conflictCount ) @@ -678,12 +865,22 @@ class WebDavSyncService(private val context: Context) { private fun downloadRemoteNotes( sardine: Sardine, serverUrl: String, - includeRootFallback: Boolean = false // πŸ†• v1.2.2: Only for restore from server + includeRootFallback: Boolean = false, // πŸ†• v1.2.2: Only for restore from server + forceOverwrite: Boolean = false, // πŸ†• v1.3.0: For OVERWRITE_DUPLICATES mode + deletionTracker: DeletionTracker = storage.loadDeletionTracker() // πŸ†• v1.3.0: Allow passing fresh tracker ): DownloadResult { var downloadedCount = 0 var conflictCount = 0 + var skippedDeleted = 0 // NEW: Track skipped deleted notes val processedIds = mutableSetOf() // πŸ†• v1.2.2: Track already loaded notes + Logger.d(TAG, "πŸ“₯ downloadRemoteNotes() called:") + Logger.d(TAG, " includeRootFallback: $includeRootFallback") + Logger.d(TAG, " forceOverwrite: $forceOverwrite") + + // Use provided deletion tracker (allows fresh tracker from restore) + var trackerModified = false + try { // πŸ†• PHASE 1: Download from /notes/ (new structure v1.2.1+) val notesUrl = getNotesUrl(serverUrl) @@ -703,6 +900,24 @@ class WebDavSyncService(private val context: Context) { val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } val remoteNote = Note.fromJson(jsonContent) ?: continue + // NEW: Check if note was deleted locally + if (deletionTracker.isDeleted(remoteNote.id)) { + val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id) + + // Smart check: Was note re-created on server after deletion? + if (deletedAt != null && remoteNote.updatedAt > deletedAt) { + Logger.d(TAG, " πŸ“ Note re-created on server after deletion: ${remoteNote.id}") + deletionTracker.removeDeletion(remoteNote.id) + trackerModified = true + // Continue with download below + } else { + Logger.d(TAG, " ⏭️ Skipping deleted note: ${remoteNote.id}") + skippedDeleted++ + processedIds.add(remoteNote.id) + continue + } + } + processedIds.add(remoteNote.id) // πŸ†• Mark as processed val localNote = storage.loadNote(remoteNote.id) @@ -714,6 +929,12 @@ class WebDavSyncService(private val context: Context) { downloadedCount++ Logger.d(TAG, " βœ… Downloaded from /notes/: ${remoteNote.id}") } + forceOverwrite -> { + // OVERWRITE mode: Always replace regardless of timestamps + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}") + } localNote.updatedAt < remoteNote.updatedAt -> { // Remote is newer if (localNote.syncStatus == SyncStatus.PENDING) { @@ -729,7 +950,7 @@ class WebDavSyncService(private val context: Context) { } } } - Logger.d(TAG, " πŸ“Š Phase 1 complete: $downloadedCount notes from /notes/") + Logger.d(TAG, " πŸ“Š Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted)") } else { Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1") } @@ -738,7 +959,7 @@ class WebDavSyncService(private val context: Context) { // ⚠️ 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)") + Logger.d(TAG, "πŸ” Phase 2: Checking ROOT at: $rootUrl (v1.2.0 compat)") try { val rootResources = sardine.list(rootUrl) @@ -770,6 +991,19 @@ class WebDavSyncService(private val context: Context) { continue } + // NEW: Check deletion tracker + if (deletionTracker.isDeleted(remoteNote.id)) { + val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id) + if (deletedAt != null && remoteNote.updatedAt > deletedAt) { + deletionTracker.removeDeletion(remoteNote.id) + trackerModified = true + } else { + Logger.d(TAG, " ⏭️ Skipping deleted note: ${remoteNote.id}") + skippedDeleted++ + continue + } + } + processedIds.add(remoteNote.id) val localNote = storage.loadNote(remoteNote.id) @@ -779,6 +1013,12 @@ class WebDavSyncService(private val context: Context) { downloadedCount++ Logger.d(TAG, " βœ… Downloaded from ROOT: ${remoteNote.id}") } + forceOverwrite -> { + // OVERWRITE mode: Always replace regardless of timestamps + storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED)) + downloadedCount++ + Logger.d(TAG, " ♻️ Overwritten from ROOT: ${remoteNote.id}") + } localNote.updatedAt < remoteNote.updatedAt -> { if (localNote.syncStatus == SyncStatus.PENDING) { storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) @@ -812,12 +1052,45 @@ class WebDavSyncService(private val context: Context) { Logger.e(TAG, "❌ downloadRemoteNotes failed", e) } - Logger.d(TAG, "πŸ“Š Total download result: $downloadedCount notes, $conflictCount conflicts") + // NEW: Save deletion tracker if modified + if (trackerModified) { + storage.saveDeletionTracker(deletionTracker) + Logger.d(TAG, "πŸ’Ύ Deletion tracker updated") + } + + Logger.d(TAG, "πŸ“Š Total: $downloadedCount downloaded, $conflictCount conflicts, $skippedDeleted deleted") return DownloadResult(downloadedCount, conflictCount) } private fun saveLastSyncTimestamp() { val now = System.currentTimeMillis() + + // v1.3.0: Save E-Tag only for JSON (Markdown uses timestamp check) + try { + val sardine = getSardine() + val serverUrl = getServerUrl() + + if (sardine != null && serverUrl != null) { + val notesUrl = getNotesUrl(serverUrl) + + // JSON E-Tag only + val notesResources = sardine.list(notesUrl, 0) + val notesETag = notesResources.firstOrNull()?.contentLength?.toString() + + prefs.edit() + .putLong(Constants.KEY_LAST_SYNC, now) + .putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) + .putString("notes_collection_etag", notesETag) + .apply() + + Logger.d(TAG, "πŸ’Ύ Saved sync timestamp + JSON E-Tag") + return + } + } catch (e: Exception) { + Logger.w(TAG, "Failed to save E-Tag: ${e.message}") + } + + // Fallback: Save timestamp only prefs.edit() .putLong(Constants.KEY_LAST_SYNC, now) .putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // πŸ”₯ v1.1.2: Track successful sync @@ -833,10 +1106,13 @@ class WebDavSyncService(private val context: Context) { } /** - * Restore all notes from server - overwrites local storage + * Restore all notes from server with different modes (v1.3.0) + * @param mode RestoreMode (REPLACE, MERGE, or OVERWRITE_DUPLICATES) * @return RestoreResult with count of restored notes */ - suspend fun restoreFromServer(): RestoreResult = withContext(Dispatchers.IO) { + suspend fun restoreFromServer( + mode: dev.dettmer.simplenotes.backup.RestoreMode = dev.dettmer.simplenotes.backup.RestoreMode.REPLACE + ): RestoreResult = withContext(Dispatchers.IO) { return@withContext try { val sardine = getSardine() ?: return@withContext RestoreResult( isSuccess = false, @@ -850,20 +1126,57 @@ class WebDavSyncService(private val context: Context) { restoredCount = 0 ) - Logger.d(TAG, "πŸ”„ Starting restore from server...") + Logger.d(TAG, "═══════════════════════════════════════") + Logger.d(TAG, "πŸ”„ restoreFromServer() ENTRY") + Logger.d(TAG, "Mode: $mode") + Logger.d(TAG, "Thread: ${Thread.currentThread().name}") - // Clear local storage FIRST - Logger.d(TAG, "πŸ—‘οΈ Clearing local storage...") - storage.deleteAllNotes() + // βœ… v1.3.0 FIX: WICHTIG - Deletion Tracker bei ALLEN Modi clearen! + // Restore bedeutet: "Server ist die Quelle der Wahrheit" + // β†’ Lokale Deletion-History ist irrelevant + Logger.d(TAG, "πŸ—‘οΈ Clearing deletion tracker (restore mode)") + storage.clearDeletionTracker() - // πŸ†• v1.2.2: Use downloadRemoteNotes() with Root fallback enabled + // Determine forceOverwrite flag + val forceOverwrite = (mode == dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES) + Logger.d(TAG, "forceOverwrite: $forceOverwrite") + + // Mode-specific preparation + when (mode) { + dev.dettmer.simplenotes.backup.RestoreMode.REPLACE -> { + // Clear everything + Logger.d(TAG, "πŸ—‘οΈ REPLACE mode: Clearing local storage...") + storage.deleteAllNotes() + // Tracker already cleared above + } + dev.dettmer.simplenotes.backup.RestoreMode.MERGE -> { + // Keep local notes, just add from server + Logger.d(TAG, "πŸ”€ MERGE mode: Keeping local notes...") + // βœ… Tracker cleared β†’ Server notes will NOT be skipped + } + dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES -> { + // Will overwrite in downloadRemoteNotes if needed + Logger.d(TAG, "♻️ OVERWRITE mode: Will force update duplicates...") + // βœ… Tracker cleared β†’ Server notes will NOT be skipped + } + } + + // πŸ†• v1.2.2: Use downloadRemoteNotes() with Root fallback + forceOverwrite + // πŸ†• v1.3.0: Pass FRESH empty tracker to avoid loading stale cached data + Logger.d(TAG, "πŸ“‘ Calling downloadRemoteNotes() - includeRootFallback: true, forceOverwrite: $forceOverwrite") + val emptyTracker = DeletionTracker() // Fresh empty tracker after clear val result = downloadRemoteNotes( sardine = sardine, serverUrl = serverUrl, - includeRootFallback = true // βœ… Enable backward compatibility for restore + includeRootFallback = true, // βœ… Enable backward compatibility for restore + forceOverwrite = forceOverwrite, // βœ… v1.3.0: Force overwrite for OVERWRITE_DUPLICATES mode + deletionTracker = emptyTracker // βœ… v1.3.0: Use fresh tracker to prevent skipping ) - if (result.downloadedCount == 0) { + Logger.d(TAG, "πŸ“Š Download result: downloaded=${result.downloadedCount}, conflicts=${result.conflictCount}") + + if (result.downloadedCount == 0 && mode == dev.dettmer.simplenotes.backup.RestoreMode.REPLACE) { + Logger.w(TAG, "⚠️ No notes found on server!") return@withContext RestoreResult( isSuccess = false, errorMessage = "Keine Notizen auf Server gefunden", @@ -871,9 +1184,31 @@ class WebDavSyncService(private val context: Context) { ) } + // NOTE: Code that removes restored notes from deletion tracker is now REDUNDANT + // because we cleared the tracker at the start. But keep it for safety: + if (result.downloadedCount > 0) { + val deletionTracker = storage.loadDeletionTracker() + val allNotes = storage.loadAllNotes() + var trackingModified = false + + allNotes.forEach { note -> + if (deletionTracker.isDeleted(note.id)) { + deletionTracker.removeDeletion(note.id) + trackingModified = true + Logger.d(TAG, "πŸ”“ Removed from deletion tracker: ${note.id} (restored from server)") + } + } + + if (trackingModified) { + storage.saveDeletionTracker(deletionTracker) + Logger.d(TAG, "πŸ’Ύ Updated deletion tracker after restore") + } + } + saveLastSyncTimestamp() Logger.d(TAG, "βœ… Restore completed: ${result.downloadedCount} notes") + Logger.d(TAG, "═══════════════════════════════════════") RestoreResult( isSuccess = true, @@ -882,7 +1217,12 @@ class WebDavSyncService(private val context: Context) { ) } catch (e: Exception) { - Logger.e(TAG, "❌ Restore failed", e) + Logger.e(TAG, "═══════════════════════════════════════") + Logger.e(TAG, "πŸ’₯ restoreFromServer() EXCEPTION") + Logger.e(TAG, "Exception type: ${e.javaClass.name}") + Logger.e(TAG, "Exception message: ${e.message}") + e.printStackTrace() + Logger.e(TAG, "═══════════════════════════════════════") RestoreResult( isSuccess = false, errorMessage = e.message ?: "Unbekannter Fehler", @@ -970,6 +1310,295 @@ class WebDavSyncService(private val context: Context) { 0 } } + + /** + * Auto-import Markdown files during regular sync (v1.3.0) + * Called automatically if KEY_MARKDOWN_AUTO_IMPORT is enabled + */ + private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int { + return try { + Logger.d(TAG, "πŸ“ Importing Markdown files...") + + val mdUrl = getMarkdownUrl(serverUrl) + + // Check if notes-md/ exists + if (!sardine.exists(mdUrl)) { + Logger.d(TAG, " ⚠️ notes-md/ directory not found - skipping") + return 0 + } + + val mdResources = sardine.list(mdUrl).filter { !it.isDirectory && it.name.endsWith(".md") } + var importedCount = 0 + + Logger.d(TAG, " πŸ“‚ Found ${mdResources.size} markdown files") + + for (resource in mdResources) { + try { + Logger.d(TAG, " πŸ” Processing: ${resource.name}, modified=${resource.modified}") + + // Build full URL + val mdFileUrl = mdUrl.trimEnd('/') + "/" + resource.name + + // Download MD content + val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() } + Logger.d(TAG, " Downloaded ${mdContent.length} chars") + + // Parse to Note + val mdNote = Note.fromMarkdown(mdContent) + if (mdNote == null) { + Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null") + continue + } + Logger.d(TAG, " Parsed: id=${mdNote.id}, title=${mdNote.title}, updatedAt=${Date(mdNote.updatedAt)}") + + val localNote = storage.loadNote(mdNote.id) + Logger.d(TAG, " Local note: ${if (localNote == null) "NOT FOUND" else "exists, updatedAt=${Date(localNote.updatedAt)}, syncStatus=${localNote.syncStatus}"}") + + // Use server file modification time for reliable change detection + val serverModifiedTime = resource.modified?.time ?: 0L + Logger.d(TAG, " Comparison: serverModified=$serverModifiedTime, localUpdated=${localNote?.updatedAt ?: 0L}") + + // Conflict resolution: Last-Write-Wins + when { + localNote == null -> { + // New note from desktop + storage.saveNote(mdNote.copy(syncStatus = SyncStatus.SYNCED)) + importedCount++ + Logger.d(TAG, " βœ… Imported new from Markdown: ${mdNote.title}") + } + serverModifiedTime > localNote.updatedAt -> { + // Server file is newer (based on modification time) + Logger.d(TAG, " Decision: Server is newer!") + if (localNote.syncStatus == SyncStatus.PENDING) { + // Conflict: local has pending changes + storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT)) + Logger.w(TAG, " ⚠️ Conflict: Markdown vs local pending: ${mdNote.id}") + } else { + // Content comparison to preserve timestamps on export-only updates + val contentChanged = mdNote.content != localNote.content || + mdNote.title != localNote.title + + // Detect if YAML timestamp wasn't updated despite content change + val yamlInconsistent = contentChanged && mdNote.updatedAt <= localNote.updatedAt + + // Log inconsistencies for debugging + if (yamlInconsistent) { + Logger.w(TAG, " ⚠️ Inconsistency: ${mdNote.title}") + Logger.w(TAG, " Content changed but YAML timestamp not updated") + Logger.w(TAG, " YAML: ${mdNote.updatedAt}, Local: ${localNote.updatedAt}") + Logger.w(TAG, " Using current time as fallback") + } + + // Determine final timestamp with auto-correction + val finalUpdatedAt: Long = when { + // No content change β†’ preserve local timestamp (export-only) + !contentChanged -> localNote.updatedAt + + // Content changed + YAML timestamp properly updated + !yamlInconsistent -> mdNote.updatedAt + + // Content changed + YAML timestamp NOT updated β†’ use current time + else -> System.currentTimeMillis() + } + + storage.saveNote(mdNote.copy( + updatedAt = finalUpdatedAt, + syncStatus = SyncStatus.SYNCED + )) + importedCount++ + + // Detailed logging + when { + !contentChanged -> Logger.d(TAG, " βœ… Re-synced (export-only, timestamp preserved): ${mdNote.title}") + yamlInconsistent -> Logger.d(TAG, " βœ… Updated (content changed, timestamp corrected): ${mdNote.title}") + else -> Logger.d(TAG, " βœ… Updated (content changed, YAML timestamp valid): ${mdNote.title}") + } + } + } + else -> { + Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: local is newer (server=$serverModifiedTime, local=${localNote.updatedAt})") + } + } + } catch (e: Exception) { + Logger.e(TAG, " ⚠️ Failed to import ${resource.name}", e) + // Continue with other files + } + } + + Logger.d(TAG, " πŸ“Š Markdown import complete: $importedCount notes") + importedCount + + } catch (e: Exception) { + Logger.e(TAG, "❌ Markdown import failed", e) + 0 + } + } + + /** + * Finds a Markdown file by scanning YAML frontmatter for note ID + * Used when local note is deleted and title is unavailable + * + * @param sardine Sardine client + * @param mdUrl Base URL of notes-md/ directory + * @param noteId The note ID to search for + * @return Filename if found, null otherwise + */ + private suspend fun findMarkdownFileByNoteId( + sardine: Sardine, + mdUrl: String, + noteId: String + ): String? = withContext(Dispatchers.IO) { + return@withContext try { + Logger.d(TAG, "πŸ” Scanning MD files for ID: $noteId") + val resources = sardine.list(mdUrl) + + for (resource in resources) { + if (resource.isDirectory || !resource.name.endsWith(".md")) { + continue + } + + try { + // Download MD content + val mdFileUrl = mdUrl.trimEnd('/') + "/" + resource.name + val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() } + + // Parse YAML frontmatter for ID + val idMatch = Regex("""^---\s*\n.*?id:\s*([a-f0-9-]+)""", RegexOption.DOT_MATCHES_ALL) + .find(mdContent) + + if (idMatch?.groupValues?.get(1) == noteId) { + Logger.d(TAG, " βœ… Found MD file: ${resource.name}") + return@withContext resource.name + } + } catch (e: Exception) { + Logger.w(TAG, " ⚠️ Failed to parse ${resource.name}: ${e.message}") + // Continue with next file + } + } + + Logger.w(TAG, " ❌ No MD file found for ID: $noteId") + null + } catch (e: Exception) { + Logger.e(TAG, "Failed to scan MD files: ${e.message}") + null + } + } + + /** + * Deletes a note from the server (JSON + Markdown) + * Does NOT delete from local storage! + * + * @param noteId The ID of the note to delete + * @return true if at least one file was deleted, false otherwise + */ + suspend fun deleteNoteFromServer(noteId: String): Boolean = withContext(Dispatchers.IO) { + return@withContext try { + val sardine = getSardine() ?: return@withContext false + val serverUrl = getServerUrl() ?: return@withContext false + + var deletedJson = false + var deletedMd = false + + // Delete JSON + val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json" + if (sardine.exists(jsonUrl)) { + sardine.delete(jsonUrl) + deletedJson = true + Logger.d(TAG, "πŸ—‘οΈ Deleted from server: $noteId.json") + } + + // Delete Markdown (v1.3.0: YAML-scan based approach) + val mdBaseUrl = getMarkdownUrl(serverUrl) + val note = storage.loadNote(noteId) + var mdFilenameToDelete: String? = null + + if (note != null) { + // Fast path: Note still exists locally, use title + mdFilenameToDelete = sanitizeFilename(note.title) + ".md" + Logger.d(TAG, "πŸ” MD deletion: Using title from local note: $mdFilenameToDelete") + } else { + // Fallback: Note deleted locally, scan YAML frontmatter + Logger.d(TAG, "⚠️ MD deletion: Note not found locally, scanning YAML...") + mdFilenameToDelete = findMarkdownFileByNoteId(sardine, mdBaseUrl, noteId) + } + + if (mdFilenameToDelete != null) { + val mdUrl = mdBaseUrl.trimEnd('/') + "/" + mdFilenameToDelete + if (sardine.exists(mdUrl)) { + sardine.delete(mdUrl) + deletedMd = true + Logger.d(TAG, "πŸ—‘οΈ Deleted from server: $mdFilenameToDelete") + } else { + Logger.w(TAG, "⚠️ MD file not found: $mdFilenameToDelete") + } + } else { + Logger.w(TAG, "⚠️ Could not determine MD filename for note $noteId") + } + + if (!deletedJson && !deletedMd) { + Logger.w(TAG, "⚠️ Note $noteId not found on server") + return@withContext false + } + + // Remove from deletion tracker (was explicitly deleted from server) + val deletionTracker = storage.loadDeletionTracker() + if (deletionTracker.isDeleted(noteId)) { + deletionTracker.removeDeletion(noteId) + storage.saveDeletionTracker(deletionTracker) + Logger.d(TAG, "πŸ”“ Removed from deletion tracker: $noteId") + } + + true + } catch (e: Exception) { + Logger.e(TAG, "Failed to delete note from server: $noteId", e) + false + } + } + + /** + * Manual Markdown sync: Export all notes + Import all MD files + * Used by manual sync button in settings (when Auto-Sync is OFF) + * + * @return ManualMarkdownSyncResult with export and import counts + */ + suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) { + return@withContext try { + val sardine = getSardine() ?: throw Exception("Sardine client konnte nicht erstellt werden") + val serverUrl = getServerUrl() ?: throw Exception("Server-URL nicht konfiguriert") + + val username = prefs.getString(Constants.KEY_USERNAME, "") ?: "" + val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: "" + + if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { + throw Exception("WebDAV-Server nicht vollstΓ€ndig konfiguriert") + } + + Logger.d(TAG, "πŸ”„ Manual Markdown Sync START") + + // Step 1: Export alle lokalen Notizen nach Markdown + val exportedCount = exportAllNotesToMarkdown( + serverUrl = serverUrl, + username = username, + password = password + ) + Logger.d(TAG, " βœ… Export: $exportedCount notes") + + // Step 2: Import alle Server-Markdown-Dateien + val importedCount = importMarkdownFiles(sardine, serverUrl) + Logger.d(TAG, " βœ… Import: $importedCount notes") + + Logger.d(TAG, "πŸŽ‰ Manual Markdown Sync COMPLETE: exported=$exportedCount, imported=$importedCount") + + ManualMarkdownSyncResult( + exportedCount = exportedCount, + importedCount = importedCount + ) + + } catch (e: Exception) { + Logger.e(TAG, "❌ Manual Markdown Sync FAILED", e) + throw e + } + } } data class RestoreResult( diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt index 9b07ede..27a5a4c 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt @@ -23,6 +23,10 @@ object Constants { const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled" const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled" + // πŸ”₯ v1.3.0: Performance & Multi-Device Sync + const val KEY_ALWAYS_CHECK_SERVER = "always_check_server" + const val KEY_ALWAYS_DELETE_FROM_SERVER = "always_delete_from_server" + // WorkManager const val SYNC_WORK_TAG = "notes_sync" const val SYNC_DELAY_SECONDS = 5L diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 12c7ef0..0876d59 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -429,11 +429,11 @@ - + @@ -441,34 +441,47 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="πŸ“ Markdown Export (Desktop-Zugriff)" + android:text="πŸ”„ Markdown Auto-Sync" android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> + android:checked="false" /> - -