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
|
### Fixed
|
||||||
- **Backward Compatibility for v1.2.0 Users (Critical)**
|
- **Backward Compatibility for v1.2.0 Users (Critical)**
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
[](https://m3.material.io/)
|
[](https://m3.material.io/)
|
||||||
[](LICENSE)
|
[](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)**
|
**📱 [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**
|
**🌍 Languages:** [Deutsch](README.md) · **English**
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
[](https://m3.material.io/)
|
[](https://m3.material.io/)
|
||||||
[](LICENSE)
|
[](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)**
|
**📱 [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)
|
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ android {
|
|||||||
applicationId = "dev.dettmer.simplenotes"
|
applicationId = "dev.dettmer.simplenotes"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 7 // 🔧 v1.2.2: Backward compatibility for v1.2.0 migration
|
versionCode = 8 // 🚀 v1.3.0: Multi-Device Sync with deletion tracking
|
||||||
versionName = "1.2.2" // 🔧 v1.2.2: Dual-mode download (Root + /notes/)
|
versionName = "1.3.0" // 🚀 v1.3.0: Multi-Device Sync, E-Tag caching, Markdown auto-import
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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.floatingactionbutton.FloatingActionButton
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.google.android.material.card.MaterialCardView
|
import com.google.android.material.card.MaterialCardView
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
import dev.dettmer.simplenotes.utils.showToast
|
import dev.dettmer.simplenotes.utils.showToast
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import android.widget.TextView
|
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 androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -320,36 +326,20 @@ class MainActivity : AppCompatActivity() {
|
|||||||
): Boolean = false
|
): Boolean = false
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
val position = viewHolder.adapterPosition
|
val position = viewHolder.bindingAdapterPosition
|
||||||
val note = adapter.currentList[position]
|
val swipedNote = adapter.currentList[position]
|
||||||
val notesCopy = adapter.currentList.toMutableList()
|
|
||||||
|
|
||||||
// Track pending deletion to prevent flicker
|
// Store original list BEFORE removing note
|
||||||
pendingDeletions.add(note.id)
|
val originalList = adapter.currentList.toList()
|
||||||
|
|
||||||
// Remove from list immediately for visual feedback
|
// Remove from list for visual feedback (NOT from storage yet!)
|
||||||
notesCopy.removeAt(position)
|
val listWithoutNote = originalList.toMutableList().apply {
|
||||||
adapter.submitList(notesCopy)
|
removeAt(position)
|
||||||
|
}
|
||||||
|
adapter.submitList(listWithoutNote)
|
||||||
|
|
||||||
// Show Snackbar with UNDO
|
// Show dialog with ability to restore
|
||||||
Snackbar.make(
|
showServerDeletionDialog(swipedNote, originalList)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||||
@@ -361,6 +351,104 @@ class MainActivity : AppCompatActivity() {
|
|||||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
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() {
|
private fun setupFab() {
|
||||||
fabAddNote.setOnClickListener {
|
fabAddNote.setOnClickListener {
|
||||||
openNoteEditor(null)
|
openNoteEditor(null)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.os.PowerManager
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.RadioButton
|
import android.widget.RadioButton
|
||||||
@@ -57,14 +58,15 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
private lateinit var editTextUsername: EditText
|
private lateinit var editTextUsername: EditText
|
||||||
private lateinit var editTextPassword: EditText
|
private lateinit var editTextPassword: EditText
|
||||||
private lateinit var switchAutoSync: SwitchCompat
|
private lateinit var switchAutoSync: SwitchCompat
|
||||||
private lateinit var switchMarkdownExport: SwitchCompat
|
private lateinit var switchMarkdownAutoSync: SwitchCompat
|
||||||
private lateinit var buttonTestConnection: Button
|
private lateinit var buttonTestConnection: Button
|
||||||
private lateinit var buttonSyncNow: Button
|
private lateinit var buttonSyncNow: Button
|
||||||
private lateinit var buttonCreateBackup: Button
|
private lateinit var buttonCreateBackup: Button
|
||||||
private lateinit var buttonRestoreFromFile: Button
|
private lateinit var buttonRestoreFromFile: Button
|
||||||
private lateinit var buttonRestoreFromServer: Button
|
private lateinit var buttonRestoreFromServer: Button
|
||||||
private lateinit var buttonImportMarkdown: Button
|
private lateinit var buttonManualMarkdownSync: Button
|
||||||
private lateinit var textViewServerStatus: TextView
|
private lateinit var textViewServerStatus: TextView
|
||||||
|
private lateinit var textViewManualSyncInfo: TextView
|
||||||
|
|
||||||
// Protocol Selection UI
|
// Protocol Selection UI
|
||||||
private lateinit var protocolRadioGroup: RadioGroup
|
private lateinit var protocolRadioGroup: RadioGroup
|
||||||
@@ -130,14 +132,15 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
editTextUsername = findViewById(R.id.editTextUsername)
|
editTextUsername = findViewById(R.id.editTextUsername)
|
||||||
editTextPassword = findViewById(R.id.editTextPassword)
|
editTextPassword = findViewById(R.id.editTextPassword)
|
||||||
switchAutoSync = findViewById(R.id.switchAutoSync)
|
switchAutoSync = findViewById(R.id.switchAutoSync)
|
||||||
switchMarkdownExport = findViewById(R.id.switchMarkdownExport)
|
switchMarkdownAutoSync = findViewById(R.id.switchMarkdownAutoSync)
|
||||||
buttonTestConnection = findViewById(R.id.buttonTestConnection)
|
buttonTestConnection = findViewById(R.id.buttonTestConnection)
|
||||||
buttonSyncNow = findViewById(R.id.buttonSyncNow)
|
buttonSyncNow = findViewById(R.id.buttonSyncNow)
|
||||||
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
|
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
|
||||||
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
|
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
|
||||||
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
|
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
|
||||||
buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown)
|
buttonManualMarkdownSync = findViewById(R.id.buttonManualMarkdownSync)
|
||||||
textViewServerStatus = findViewById(R.id.textViewServerStatus)
|
textViewServerStatus = findViewById(R.id.textViewServerStatus)
|
||||||
|
textViewManualSyncInfo = findViewById(R.id.textViewManualSyncInfo)
|
||||||
|
|
||||||
// Protocol Selection UI
|
// Protocol Selection UI
|
||||||
protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
|
protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
|
||||||
@@ -180,7 +183,14 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
|
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
|
||||||
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
|
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
|
||||||
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
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
|
// Update hint text based on selected protocol
|
||||||
updateProtocolHint()
|
updateProtocolHint()
|
||||||
@@ -269,17 +279,16 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
|
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
buttonImportMarkdown.setOnClickListener {
|
buttonManualMarkdownSync.setOnClickListener {
|
||||||
saveSettings()
|
performManualMarkdownSync()
|
||||||
importMarkdownChanges()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
|
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
|
||||||
onAutoSyncToggled(isChecked)
|
onAutoSyncToggled(isChecked)
|
||||||
}
|
}
|
||||||
|
|
||||||
switchMarkdownExport.setOnCheckedChangeListener { _, isChecked ->
|
switchMarkdownAutoSync.setOnCheckedChangeListener { _, isChecked ->
|
||||||
onMarkdownExportToggled(isChecked)
|
onMarkdownAutoSyncToggled(isChecked)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear error when user starts typing again
|
// 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) {
|
if (enabled) {
|
||||||
// Initial-Export wenn Feature aktiviert wird
|
// Initial-Export wenn Feature aktiviert wird
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -559,7 +568,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
if (currentNoteCount > 0) {
|
if (currentNoteCount > 0) {
|
||||||
// Zeige Progress-Dialog
|
// Zeige Progress-Dialog
|
||||||
val progressDialog = ProgressDialog(this@SettingsActivity).apply {
|
val progressDialog = ProgressDialog(this@SettingsActivity).apply {
|
||||||
setTitle("Markdown-Export")
|
setTitle("Markdown Auto-Sync")
|
||||||
setMessage("Exportiere Notizen nach Markdown...")
|
setMessage("Exportiere Notizen nach Markdown...")
|
||||||
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
|
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
|
||||||
max = currentNoteCount
|
max = currentNoteCount
|
||||||
@@ -577,7 +586,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||||
progressDialog.dismiss()
|
progressDialog.dismiss()
|
||||||
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
|
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
|
||||||
switchMarkdownExport.isChecked = false
|
switchMarkdownAutoSync.isChecked = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,8 +606,13 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
progressDialog.dismiss()
|
progressDialog.dismiss()
|
||||||
|
|
||||||
// Speichere Einstellung
|
// Speichere beide Einstellungen
|
||||||
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
|
prefs.edit()
|
||||||
|
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
|
||||||
|
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
updateMarkdownButtonVisibility()
|
||||||
|
|
||||||
// Erfolgs-Nachricht
|
// Erfolgs-Nachricht
|
||||||
showToast("✅ $exportedCount Notizen nach Markdown exportiert")
|
showToast("✅ $exportedCount Notizen nach Markdown exportiert")
|
||||||
@@ -608,76 +622,35 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
showToast("❌ Export fehlgeschlagen: ${e.message}")
|
showToast("❌ Export fehlgeschlagen: ${e.message}")
|
||||||
|
|
||||||
// Deaktiviere Toggle bei Fehler
|
// Deaktiviere Toggle bei Fehler
|
||||||
switchMarkdownExport.isChecked = false
|
switchMarkdownAutoSync.isChecked = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Keine Notizen vorhanden - speichere Einstellung direkt
|
// Keine Notizen vorhanden - speichere Einstellungen direkt
|
||||||
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
|
prefs.edit()
|
||||||
showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert")
|
.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) {
|
} 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}")
|
showToast("Fehler: ${e.message}")
|
||||||
switchMarkdownExport.isChecked = false
|
switchMarkdownAutoSync.isChecked = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Deaktivieren - nur Setting speichern
|
// Deaktivieren - Settings speichern
|
||||||
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
|
prefs.edit()
|
||||||
showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
|
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
|
||||||
}
|
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
|
||||||
}
|
.apply()
|
||||||
|
|
||||||
private fun importMarkdownChanges() {
|
updateMarkdownButtonVisibility()
|
||||||
// Prüfen ob Server konfiguriert ist
|
showToast("Markdown Auto-Sync deaktiviert - nur JSON-Sync aktiv")
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,7 +913,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// Refresh MainActivity's note list
|
// Refresh MainActivity's note list
|
||||||
setResult(RESULT_OK)
|
setResult(RESULT_OK)
|
||||||
broadcastNotesChanged()
|
broadcastNotesChanged(result.imported_notes)
|
||||||
} else {
|
} else {
|
||||||
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
|
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)
|
* Server-Restore mit Restore-Modi (v1.3.0)
|
||||||
* 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+
|
|
||||||
*/
|
*/
|
||||||
private fun performRestoreFromServer(mode: RestoreMode) {
|
private fun performRestoreFromServer(mode: RestoreMode) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -970,7 +938,6 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Logger.d(TAG, "📥 Restoring from server (mode: $mode)")
|
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)
|
// Auto-Backup erstellen (Sicherheitsnetz)
|
||||||
val autoBackupUri = backupManager.createAutoBackup()
|
val autoBackupUri = backupManager.createAutoBackup()
|
||||||
@@ -981,8 +948,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
// Server-Restore durchführen
|
// Server-Restore durchführen
|
||||||
val webdavService = WebDavSyncService(this@SettingsActivity)
|
val webdavService = WebDavSyncService(this@SettingsActivity)
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
// Nutzt alte Funktion (immer REPLACE)
|
webdavService.restoreFromServer(mode) // ✅ Pass mode parameter
|
||||||
webdavService.restoreFromServer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
progressDialog.dismiss()
|
progressDialog.dismiss()
|
||||||
@@ -990,7 +956,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
|
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
|
||||||
setResult(RESULT_OK)
|
setResult(RESULT_OK)
|
||||||
broadcastNotesChanged()
|
broadcastNotesChanged(result.restoredCount)
|
||||||
} else {
|
} else {
|
||||||
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
|
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
|
||||||
}
|
}
|
||||||
@@ -1005,13 +971,70 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
/**
|
/**
|
||||||
* Sendet Broadcast dass Notizen geändert wurden
|
* 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)
|
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
|
||||||
intent.putExtra("success", true)
|
intent.putExtra("success", true)
|
||||||
intent.putExtra("syncedCount", 0)
|
intent.putExtra("syncedCount", count)
|
||||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
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
|
* 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
|
package dev.dettmer.simplenotes.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class NotesStorage(private val context: Context) {
|
class NotesStorage(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NotesStorage"
|
||||||
|
}
|
||||||
|
|
||||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
private val notesDir: File = File(context.filesDir, "notes").apply {
|
||||||
if (!exists()) mkdirs()
|
if (!exists()) mkdirs()
|
||||||
}
|
}
|
||||||
@@ -34,19 +41,89 @@ class NotesStorage(private val context: Context) {
|
|||||||
|
|
||||||
fun deleteNote(id: String): Boolean {
|
fun deleteNote(id: String): Boolean {
|
||||||
val file = File(notesDir, "$id.json")
|
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 {
|
fun deleteAllNotes(): Boolean {
|
||||||
return try {
|
return try {
|
||||||
notesDir.listFiles()
|
val notes = loadAllNotes()
|
||||||
?.filter { it.extension == "json" }
|
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||||
?.forEach { it.delete() }
|
|
||||||
|
for (note in notes) {
|
||||||
|
deleteNote(note.id) // Uses trackDeletion() automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to delete all notes", e)
|
||||||
false
|
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
|
fun getNotesDir(): File = notesDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
|
|||||||
import com.thegrizzlylabs.sardineandroid.Sardine
|
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
@@ -21,8 +22,17 @@ import java.net.NetworkInterface
|
|||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.util.Date
|
||||||
import javax.net.SocketFactory
|
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) {
|
class WebDavSyncService(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
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)
|
* Prüft ob lokale Änderungen seit letztem Sync vorhanden sind (v1.1.2)
|
||||||
* Performance-Optimierung: Vermeidet unnötige Sync-Operationen
|
* Performance-Optimierung: Vermeidet unnötige Sync-Operationen
|
||||||
@@ -266,31 +401,50 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
return@withContext try {
|
return@withContext try {
|
||||||
val lastSyncTime = getLastSyncTimestamp()
|
val lastSyncTime = getLastSyncTimestamp()
|
||||||
|
|
||||||
// Wenn noch nie gesynct, dann haben wir Änderungen
|
// Check 1: Never synced
|
||||||
if (lastSyncTime == 0L) {
|
if (lastSyncTime == 0L) {
|
||||||
Logger.d(TAG, "📝 Never synced - assuming changes exist")
|
Logger.d(TAG, "📝 Never synced - has changes: true")
|
||||||
return@withContext true
|
return@withContext true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe ob Notizen existieren die neuer sind als letzter Sync
|
// Check 2: Local changes
|
||||||
val storage = dev.dettmer.simplenotes.storage.NotesStorage(context)
|
val storage = NotesStorage(context)
|
||||||
val allNotes = storage.loadAllNotes()
|
val allNotes = storage.loadAllNotes()
|
||||||
|
val hasLocalChanges = allNotes.any { note ->
|
||||||
val hasChanges = allNotes.any { note ->
|
|
||||||
note.updatedAt > lastSyncTime
|
note.updatedAt > lastSyncTime
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "📊 Unsynced changes check: $hasChanges (${allNotes.size} notes total)")
|
if (hasLocalChanges) {
|
||||||
if (hasChanges) {
|
val unsyncedCount = allNotes.count { it.updatedAt > lastSyncTime }
|
||||||
val unsyncedCount = allNotes.count { note -> note.updatedAt > lastSyncTime }
|
Logger.d(TAG, "📝 Local changes: $unsyncedCount notes modified")
|
||||||
Logger.d(TAG, " → $unsyncedCount notes modified since last sync")
|
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) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to check for unsynced changes - assuming changes exist", e)
|
Logger.e(TAG, "Failed to check for unsynced changes", e)
|
||||||
// Bei Fehler lieber sync durchführen (safe default)
|
true // Safe default
|
||||||
true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +606,11 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
// Download remote notes
|
// Download remote notes
|
||||||
try {
|
try {
|
||||||
Logger.d(TAG, "⬇️ Downloading remote notes...")
|
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
|
syncedCount += downloadResult.downloadedCount
|
||||||
conflictCount += downloadResult.conflictCount
|
conflictCount += downloadResult.conflictCount
|
||||||
Logger.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
|
Logger.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
|
||||||
@@ -462,7 +620,24 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
throw e
|
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
|
// Update last sync timestamp
|
||||||
try {
|
try {
|
||||||
saveLastSyncTimestamp()
|
saveLastSyncTimestamp()
|
||||||
@@ -473,12 +648,24 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
// Non-fatal, continue
|
// 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, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
|
||||||
SyncResult(
|
SyncResult(
|
||||||
isSuccess = true,
|
isSuccess = true,
|
||||||
syncedCount = syncedCount,
|
syncedCount = effectiveSyncedCount,
|
||||||
conflictCount = conflictCount
|
conflictCount = conflictCount
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -678,12 +865,22 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
private fun downloadRemoteNotes(
|
private fun downloadRemoteNotes(
|
||||||
sardine: Sardine,
|
sardine: Sardine,
|
||||||
serverUrl: String,
|
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 {
|
): DownloadResult {
|
||||||
var downloadedCount = 0
|
var downloadedCount = 0
|
||||||
var conflictCount = 0
|
var conflictCount = 0
|
||||||
|
var skippedDeleted = 0 // NEW: Track skipped deleted notes
|
||||||
val processedIds = mutableSetOf<String>() // 🆕 v1.2.2: Track already loaded 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 {
|
try {
|
||||||
// 🆕 PHASE 1: Download from /notes/ (new structure v1.2.1+)
|
// 🆕 PHASE 1: Download from /notes/ (new structure v1.2.1+)
|
||||||
val notesUrl = getNotesUrl(serverUrl)
|
val notesUrl = getNotesUrl(serverUrl)
|
||||||
@@ -703,6 +900,24 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
||||||
val remoteNote = Note.fromJson(jsonContent) ?: continue
|
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
|
processedIds.add(remoteNote.id) // 🆕 Mark as processed
|
||||||
|
|
||||||
val localNote = storage.loadNote(remoteNote.id)
|
val localNote = storage.loadNote(remoteNote.id)
|
||||||
@@ -714,6 +929,12 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
downloadedCount++
|
downloadedCount++
|
||||||
Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}")
|
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 -> {
|
localNote.updatedAt < remoteNote.updatedAt -> {
|
||||||
// Remote is newer
|
// Remote is newer
|
||||||
if (localNote.syncStatus == SyncStatus.PENDING) {
|
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 {
|
} else {
|
||||||
Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1")
|
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
|
// ⚠️ ONLY for restore from server! Normal sync should NOT scan Root
|
||||||
if (includeRootFallback) {
|
if (includeRootFallback) {
|
||||||
val rootUrl = serverUrl.trimEnd('/')
|
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 {
|
try {
|
||||||
val rootResources = sardine.list(rootUrl)
|
val rootResources = sardine.list(rootUrl)
|
||||||
@@ -770,6 +991,19 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
continue
|
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)
|
processedIds.add(remoteNote.id)
|
||||||
val localNote = storage.loadNote(remoteNote.id)
|
val localNote = storage.loadNote(remoteNote.id)
|
||||||
|
|
||||||
@@ -779,6 +1013,12 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
downloadedCount++
|
downloadedCount++
|
||||||
Logger.d(TAG, " ✅ Downloaded from ROOT: ${remoteNote.id}")
|
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 -> {
|
localNote.updatedAt < remoteNote.updatedAt -> {
|
||||||
if (localNote.syncStatus == SyncStatus.PENDING) {
|
if (localNote.syncStatus == SyncStatus.PENDING) {
|
||||||
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
|
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
|
||||||
@@ -812,12 +1052,45 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
Logger.e(TAG, "❌ downloadRemoteNotes failed", e)
|
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)
|
return DownloadResult(downloadedCount, conflictCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLastSyncTimestamp() {
|
private fun saveLastSyncTimestamp() {
|
||||||
val now = System.currentTimeMillis()
|
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()
|
prefs.edit()
|
||||||
.putLong(Constants.KEY_LAST_SYNC, now)
|
.putLong(Constants.KEY_LAST_SYNC, now)
|
||||||
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // 🔥 v1.1.2: Track successful sync
|
.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
|
* @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 {
|
return@withContext try {
|
||||||
val sardine = getSardine() ?: return@withContext RestoreResult(
|
val sardine = getSardine() ?: return@withContext RestoreResult(
|
||||||
isSuccess = false,
|
isSuccess = false,
|
||||||
@@ -850,20 +1126,57 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
restoredCount = 0
|
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
|
// ✅ v1.3.0 FIX: WICHTIG - Deletion Tracker bei ALLEN Modi clearen!
|
||||||
Logger.d(TAG, "🗑️ Clearing local storage...")
|
// Restore bedeutet: "Server ist die Quelle der Wahrheit"
|
||||||
storage.deleteAllNotes()
|
// → 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(
|
val result = downloadRemoteNotes(
|
||||||
sardine = sardine,
|
sardine = sardine,
|
||||||
serverUrl = serverUrl,
|
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(
|
return@withContext RestoreResult(
|
||||||
isSuccess = false,
|
isSuccess = false,
|
||||||
errorMessage = "Keine Notizen auf Server gefunden",
|
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()
|
saveLastSyncTimestamp()
|
||||||
|
|
||||||
Logger.d(TAG, "✅ Restore completed: ${result.downloadedCount} notes")
|
Logger.d(TAG, "✅ Restore completed: ${result.downloadedCount} notes")
|
||||||
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
|
||||||
RestoreResult(
|
RestoreResult(
|
||||||
isSuccess = true,
|
isSuccess = true,
|
||||||
@@ -882,7 +1217,12 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} 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(
|
RestoreResult(
|
||||||
isSuccess = false,
|
isSuccess = false,
|
||||||
errorMessage = e.message ?: "Unbekannter Fehler",
|
errorMessage = e.message ?: "Unbekannter Fehler",
|
||||||
@@ -970,6 +1310,295 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
0
|
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(
|
data class RestoreResult(
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ object Constants {
|
|||||||
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
|
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
|
||||||
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_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
|
// WorkManager
|
||||||
const val SYNC_WORK_TAG = "notes_sync"
|
const val SYNC_WORK_TAG = "notes_sync"
|
||||||
const val SYNC_DELAY_SECONDS = 5L
|
const val SYNC_DELAY_SECONDS = 5L
|
||||||
|
|||||||
@@ -429,11 +429,11 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Markdown Export Toggle -->
|
<!-- Markdown Auto-Sync Toggle (fusioniert Export + Auto-Import) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
@@ -441,34 +441,47 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="📝 Markdown Export (Desktop-Zugriff)"
|
android:text="🔄 Markdown Auto-Sync"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||||
|
|
||||||
<androidx.appcompat.widget.SwitchCompat
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
android:id="@+id/switchMarkdownExport"
|
android:id="@+id/switchMarkdownAutoSync"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:checked="true" />
|
android:checked="false" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Import Markdown Button -->
|
<!-- Auto-Sync Info Text -->
|
||||||
<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 -->
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)"
|
android:text="Synchronisiert Notizen automatisch als .md Dateien (Upload + Download bei jedem Sync)"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</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