feat(v1.8.0): IMPL_016 - Server Deletion Detection

- Add DELETED_ON_SERVER to SyncStatus enum
- Add deletedOnServerCount to SyncResult
- Implement detectServerDeletions() function in WebDavSyncService
- Integrate server deletion detection in downloadRemoteNotes()
- Update UI icons in NoteCard, NoteCardGrid, NoteCardCompact
- Add string resources for deleted_on_server status
- No additional HTTP requests (uses existing PROPFIND data)
- Zero performance impact

Closes #IMPL_016
This commit is contained in:
inventory69
2026-02-08 23:27:10 +01:00
parent e9e4b87853
commit 40d7c83c84
8 changed files with 105 additions and 11 deletions

View File

@@ -89,6 +89,7 @@ class NotesAdapter(
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
SyncStatus.DELETED_ON_SERVER -> android.R.drawable.ic_menu_delete // 🆕 v1.8.0
} }
imageViewSyncStatus.setImageResource(syncIcon) imageViewSyncStatus.setImageResource(syncIcon)
imageViewSyncStatus.visibility = View.VISIBLE imageViewSyncStatus.visibility = View.VISIBLE

View File

@@ -1,8 +1,15 @@
package dev.dettmer.simplenotes.models package dev.dettmer.simplenotes.models
/**
* Sync-Status einer Notiz
*
* v1.4.0: Initial (LOCAL_ONLY, SYNCED, PENDING, CONFLICT)
* v1.8.0: DELETED_ON_SERVER hinzugefügt
*/
enum class SyncStatus { enum class SyncStatus {
LOCAL_ONLY, // Noch nie gesynct LOCAL_ONLY, // Noch nie gesynct
SYNCED, // Erfolgreich gesynct SYNCED, // Erfolgreich gesynct
PENDING, // Wartet auf Sync PENDING, // Wartet auf Sync
CONFLICT // Konflikt erkannt CONFLICT, // Konflikt erkannt
DELETED_ON_SERVER // 🆕 v1.8.0: Server hat gelöscht, lokal noch vorhanden
} }

View File

@@ -1,11 +1,18 @@
package dev.dettmer.simplenotes.sync package dev.dettmer.simplenotes.sync
/**
* Ergebnis eines Sync-Vorgangs
*
* v1.7.0: Initial
* v1.8.0: deletedOnServerCount hinzugefügt
*/
data class SyncResult( data class SyncResult(
val isSuccess: Boolean, val isSuccess: Boolean,
val syncedCount: Int = 0, val syncedCount: Int = 0,
val conflictCount: Int = 0, val conflictCount: Int = 0,
val deletedOnServerCount: Int = 0, // 🆕 v1.8.0
val errorMessage: String? = null val errorMessage: String? = null
) { ) {
val hasConflicts: Boolean val hasConflicts: Boolean get() = conflictCount > 0
get() = conflictCount > 0 val hasServerDeletions: Boolean get() = deletedOnServerCount > 0 // 🆕 v1.8.0
} }

View File

@@ -655,6 +655,7 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "📍 Step 5: Downloading remote notes") Logger.d(TAG, "📍 Step 5: Downloading remote notes")
// Download remote notes // Download remote notes
var deletedOnServerCount = 0 // 🆕 v1.8.0
try { try {
Logger.d(TAG, "⬇️ Downloading remote notes...") Logger.d(TAG, "⬇️ Downloading remote notes...")
val downloadResult = downloadRemoteNotes( val downloadResult = downloadRemoteNotes(
@@ -664,10 +665,12 @@ class WebDavSyncService(private val context: Context) {
) )
syncedCount += downloadResult.downloadedCount syncedCount += downloadResult.downloadedCount
conflictCount += downloadResult.conflictCount conflictCount += downloadResult.conflictCount
deletedOnServerCount = downloadResult.deletedOnServerCount // 🆕 v1.8.0
Logger.d( Logger.d(
TAG, TAG,
"✅ Downloaded: ${downloadResult.downloadedCount} notes, " + "✅ Downloaded: ${downloadResult.downloadedCount} notes, " +
"Conflicts: ${downloadResult.conflictCount}" "Conflicts: ${downloadResult.conflictCount}, " +
"Deleted on server: ${downloadResult.deletedOnServerCount}" // 🆕 v1.8.0
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e) Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e)
@@ -724,12 +727,16 @@ class WebDavSyncService(private val context: Context) {
if (markdownImportedCount > 0 && syncedCount > 0) { if (markdownImportedCount > 0 && syncedCount > 0) {
Logger.d(TAG, "📝 Including $markdownImportedCount Markdown file updates") Logger.d(TAG, "📝 Including $markdownImportedCount Markdown file updates")
} }
if (deletedOnServerCount > 0) { // 🆕 v1.8.0
Logger.d(TAG, "🗑️ Detected $deletedOnServerCount notes deleted on server")
}
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
SyncResult( SyncResult(
isSuccess = true, isSuccess = true,
syncedCount = effectiveSyncedCount, syncedCount = effectiveSyncedCount,
conflictCount = conflictCount conflictCount = conflictCount,
deletedOnServerCount = deletedOnServerCount // 🆕 v1.8.0
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -1038,9 +1045,45 @@ class WebDavSyncService(private val context: Context) {
private data class DownloadResult( private data class DownloadResult(
val downloadedCount: Int, val downloadedCount: Int,
val conflictCount: Int val conflictCount: Int,
val deletedOnServerCount: Int = 0 // 🆕 v1.8.0
) )
/**
* 🆕 v1.8.0: Erkennt Notizen, die auf dem Server gelöscht wurden
*
* Keine zusätzlichen HTTP-Requests! Nutzt die bereits geladene
* serverNoteIds-Liste aus dem PROPFIND-Request.
*
* @param serverNoteIds Set aller Note-IDs auf dem Server (aus PROPFIND)
* @param localNotes Alle lokalen Notizen
* @return Anzahl der als DELETED_ON_SERVER markierten Notizen
*/
private fun detectServerDeletions(
serverNoteIds: Set<String>,
localNotes: List<Note>
): Int {
var deletedCount = 0
localNotes.forEach { note ->
// Nur SYNCED-Notizen prüfen:
// - LOCAL_ONLY: War nie auf Server → irrelevant
// - PENDING: Soll hochgeladen werden → nicht überschreiben
// - CONFLICT: Wird separat behandelt
// - DELETED_ON_SERVER: Bereits markiert
if (note.syncStatus == SyncStatus.SYNCED && note.id !in serverNoteIds) {
val updatedNote = note.copy(syncStatus = SyncStatus.DELETED_ON_SERVER)
storage.saveNote(updatedNote)
deletedCount++
Logger.d(TAG, "Note '${note.title}' (${note.id}) " +
"was deleted on server, marked as DELETED_ON_SERVER")
}
}
return deletedCount
}
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements") @Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution // Sync logic requires nested conditions for comprehensive error handling and conflict resolution
private fun downloadRemoteNotes( private fun downloadRemoteNotes(
@@ -1062,6 +1105,9 @@ class WebDavSyncService(private val context: Context) {
// Use provided deletion tracker (allows fresh tracker from restore) // Use provided deletion tracker (allows fresh tracker from restore)
var trackerModified = false var trackerModified = false
// 🆕 v1.8.0: Collect server note IDs for deletion detection
val serverNoteIds = mutableSetOf<String>()
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)
@@ -1077,6 +1123,12 @@ class WebDavSyncService(private val context: Context) {
val jsonFiles = resources.filter { !it.isDirectory && it.name.endsWith(".json") } val jsonFiles = resources.filter { !it.isDirectory && it.name.endsWith(".json") }
Logger.d(TAG, " 📊 Found ${jsonFiles.size} JSON files on server") Logger.d(TAG, " 📊 Found ${jsonFiles.size} JSON files on server")
// 🆕 v1.8.0: Extract server note IDs
jsonFiles.forEach { resource ->
val noteId = resource.name.removeSuffix(".json")
serverNoteIds.add(noteId)
}
for (resource in jsonFiles) { for (resource in jsonFiles) {
val noteId = resource.name.removeSuffix(".json") val noteId = resource.name.removeSuffix(".json")
@@ -1317,8 +1369,16 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "💾 Deletion tracker updated") Logger.d(TAG, "💾 Deletion tracker updated")
} }
// 🆕 v1.8.0: Server-Deletions erkennen (nach Downloads)
val allLocalNotes = storage.loadAllNotes()
val deletedOnServerCount = detectServerDeletions(serverNoteIds, allLocalNotes)
if (deletedOnServerCount > 0) {
Logger.d(TAG, "$deletedOnServerCount note(s) detected as deleted on server")
}
Logger.d(TAG, "📊 Total: $downloadedCount downloaded, $conflictCount conflicts, $skippedDeleted deleted") Logger.d(TAG, "📊 Total: $downloadedCount downloaded, $conflictCount conflicts, $skippedDeleted deleted")
return DownloadResult(downloadedCount, conflictCount) return DownloadResult(downloadedCount, conflictCount, deletedOnServerCount)
} }
private fun saveLastSyncTimestamp() { private fun saveLastSyncTimestamp() {

View File

@@ -186,11 +186,19 @@ fun NoteCard(
SyncStatus.PENDING -> Icons.Outlined.CloudSync SyncStatus.PENDING -> Icons.Outlined.CloudSync
SyncStatus.CONFLICT -> Icons.Default.Warning SyncStatus.CONFLICT -> Icons.Default.Warning
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
},
contentDescription = when (note.syncStatus) {
SyncStatus.SYNCED -> stringResource(R.string.sync_status_synced)
SyncStatus.PENDING -> stringResource(R.string.sync_status_pending)
SyncStatus.CONFLICT -> stringResource(R.string.sync_status_conflict)
SyncStatus.LOCAL_ONLY -> stringResource(R.string.sync_status_local_only)
SyncStatus.DELETED_ON_SERVER -> stringResource(R.string.sync_status_deleted_on_server) // 🆕 v1.8.0
}, },
contentDescription = null,
tint = when (note.syncStatus) { tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
else -> MaterialTheme.colorScheme.outline else -> MaterialTheme.colorScheme.outline
}, },
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)

View File

@@ -187,11 +187,13 @@ fun NoteCardCompact(
SyncStatus.PENDING -> Icons.Outlined.CloudSync SyncStatus.PENDING -> Icons.Outlined.CloudSync
SyncStatus.CONFLICT -> Icons.Default.Warning SyncStatus.CONFLICT -> Icons.Default.Warning
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
}, },
contentDescription = null, contentDescription = null,
tint = when (note.syncStatus) { tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
else -> MaterialTheme.colorScheme.outline else -> MaterialTheme.colorScheme.outline
}, },
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)

View File

@@ -199,11 +199,13 @@ fun NoteCardGrid(
SyncStatus.PENDING -> Icons.Outlined.CloudSync SyncStatus.PENDING -> Icons.Outlined.CloudSync
SyncStatus.CONFLICT -> Icons.Default.Warning SyncStatus.CONFLICT -> Icons.Default.Warning
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
SyncStatus.DELETED_ON_SERVER -> Icons.Outlined.CloudOff // 🆕 v1.8.0
}, },
contentDescription = null, contentDescription = null,
tint = when (note.syncStatus) { tint = when (note.syncStatus) {
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
SyncStatus.DELETED_ON_SERVER -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) // 🆕 v1.8.0
else -> MaterialTheme.colorScheme.outline else -> MaterialTheme.colorScheme.outline
}, },
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)

View File

@@ -58,6 +58,13 @@
<string name="sync_status_error">Sync failed</string> <string name="sync_status_error">Sync failed</string>
<string name="sync_already_running">Sync already in progress</string> <string name="sync_already_running">Sync already in progress</string>
<!-- 🆕 v1.8.0: SyncStatus enum values -->
<string name="sync_status_synced">Synced with server</string>
<string name="sync_status_pending">Waiting for sync</string>
<string name="sync_status_conflict">Sync conflict detected</string>
<string name="sync_status_local_only">Not yet synced</string>
<string name="sync_status_deleted_on_server">Deleted on server</string>
<!-- ============================= --> <!-- ============================= -->
<!-- DELETE DIALOGS --> <!-- DELETE DIALOGS -->
<!-- ============================= --> <!-- ============================= -->