Merge pull request #4 from inventory69/release/v1.3.0
Release v1.3.0: Multi-Device Sync
This commit is contained in:
55
CHANGELOG.md
55
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)**
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](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**
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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<Note>) {
|
||||
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<CheckBox>(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)
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<DeletionRecord> = 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<DeletionRecord>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<String>() // 🆕 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -429,11 +429,11 @@
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Markdown Export Toggle -->
|
||||
<!-- Markdown Auto-Sync Toggle (fusioniert Export + Auto-Import) -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switchMarkdownExport"
|
||||
android:id="@+id/switchMarkdownAutoSync"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true" />
|
||||
android:checked="false" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Import Markdown Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonImportMarkdown"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📥 Markdown-Änderungen importieren"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<!-- Import Info Text -->
|
||||
<!-- Auto-Sync Info Text -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="Synchronisiert Notizen automatisch als .md Dateien (Upload + Download bei jedem Sync)"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<!-- Manual Sync Info (nur sichtbar wenn Auto-Sync OFF) -->
|
||||
<TextView
|
||||
android:id="@+id/textViewManualSyncInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="Oder synchronisiere Markdown-Dateien manuell:"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Manual Sync Button (nur sichtbar wenn Auto-Sync OFF) -->
|
||||
<Button
|
||||
android:id="@+id/buttonManualMarkdownSync"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Markdown synchronisieren"
|
||||
android:visibility="gone"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
15
android/app/src/main/res/layout/dialog_server_deletion.xml
Normal file
15
android/app/src/main/res/layout/dialog_server_deletion.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/checkboxAlwaysDeleteFromServer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Diese Entscheidung merken"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
|
||||
</LinearLayout>
|
||||
12
fastlane/metadata/android/de-DE/changelogs/8.txt
Normal file
12
fastlane/metadata/android/de-DE/changelogs/8.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
v1.3.0 - Multi-Device Sync
|
||||
|
||||
NEUE FEATURES:
|
||||
• Multi-Device Sync mit Deletion Tracking (keine Zombie-Notizen)
|
||||
• Wisch-Geste zum Server-Löschen (verhindert Duplikate auf anderen Geräten)
|
||||
• E-Tag Performance-Optimierung (~150ms statt 3s)
|
||||
• Markdown Auto-Sync Toggle (Export + Import vereint)
|
||||
• Manueller Markdown-Sync Button
|
||||
• Server-Wiederherstellung Modi (Merge/Replace/Overwrite)
|
||||
|
||||
Dank an Thomas aus Bielefeld!
|
||||
Kompatibel: v1.2.0-v1.3.0
|
||||
12
fastlane/metadata/android/en-US/changelogs/8.txt
Normal file
12
fastlane/metadata/android/en-US/changelogs/8.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
v1.3.0 - Multi-Device Sync
|
||||
|
||||
NEW FEATURES:
|
||||
• Multi-Device Sync with deletion tracking (no zombie notes)
|
||||
• Swipe gesture for server deletion (prevents duplicates on other devices)
|
||||
• E-Tag performance optimization (~150ms vs 3s)
|
||||
• Markdown Auto-Sync toggle (unified Export + Import)
|
||||
• Manual Markdown sync button
|
||||
• Server restore modes (Merge/Replace/Overwrite)
|
||||
|
||||
Thanks to Thomas from Bielefeld!
|
||||
Compatible: v1.2.0-v1.3.0
|
||||
Reference in New Issue
Block a user