Skip to content

Commit 60fe5b4

Browse files
committed
feat(sync): add shared deletion ledger and trashed-from-server counter
Introduces DeletionSyncManager which reads and writes a `deletions.json` ledger on the WebDAV server. When a note is deleted locally, its id is appended to the ledger so other devices can distinguish a true purge from a server-side absence. detectDeletions now accepts a DeletionTracker: a note absent from the server is hard-deleted locally if its id appears in the ledger with deletedAt >= updatedAt (timestamp guard prevents stale purge records from clobbering a legitimately re-created note). WebDavSyncService seeds the local DeletionTracker from the remote ledger before each download pass and appends entries after successful server deletions. The pending-deletions step is extracted into processPendingServerDeletions for clarity. Adds trashedDownloadedCount/trashedFromServerCount to DownloadResult and SyncResult so notes arriving pre-trashed are counted separately and shown in the sync banner.
1 parent 40c9148 commit 60fe5b4

7 files changed

Lines changed: 180 additions & 55 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package dev.dettmer.simplenotes.sync
2+
3+
import com.thegrizzlylabs.sardineandroid.Sardine
4+
import dev.dettmer.simplenotes.models.DeletionRecord
5+
import dev.dettmer.simplenotes.models.DeletionTracker
6+
import dev.dettmer.simplenotes.utils.Constants
7+
import dev.dettmer.simplenotes.utils.Logger
8+
9+
/**
10+
* Synchronises the shared `<syncFolder>/deletions.json` ledger with the server.
11+
* Mirrors the FolderSyncManager pattern: all failures are non-fatal.
12+
*/
13+
internal class DeletionSyncManager(private val urlBuilder: SyncUrlBuilder) {
14+
fun deletionsFileUrl(serverUrl: String): String =
15+
urlBuilder.getNotesUrl(serverUrl).trimEnd('/') + "/" + DELETIONS_FILE_NAME
16+
17+
/** GET deletions.json; 404 or parse error → empty tracker. */
18+
fun downloadRemote(sardine: Sardine, url: String): DeletionTracker = try {
19+
val exists = when (sardine) {
20+
is SafeSardineWrapper -> sardine.exists(url)
21+
else -> try {
22+
sardine.exists(url)
23+
} catch (_: Exception) {
24+
false
25+
}
26+
}
27+
if (!exists) {
28+
DeletionTracker()
29+
} else {
30+
sardine.get(url).use { input ->
31+
DeletionTracker.fromJson(input.reader().readText()) ?: DeletionTracker()
32+
}
33+
}
34+
} catch (e: Exception) {
35+
Logger.w(TAG, "download deletions.json failed (non-fatal): ${e.message}")
36+
DeletionTracker()
37+
}
38+
39+
/**
40+
* Read-modify-write: adds the deletion record for [noteId], dedupes by id
41+
* (keeps newest deletedAt), prunes entries older than [Constants.TRASH_RETENTION_MS],
42+
* then PUTs the result back. Best-effort — logs on failure, never throws.
43+
*/
44+
fun appendAndUpload(sardine: Sardine, url: String, noteId: String, deviceId: String) {
45+
try {
46+
val tracker = downloadRemote(sardine, url)
47+
val now = System.currentTimeMillis()
48+
val existing = tracker.deletedNotes.find { it.id == noteId }
49+
if (existing == null || now > existing.deletedAt) {
50+
tracker.deletedNotes.removeIf { it.id == noteId }
51+
tracker.deletedNotes.add(DeletionRecord(noteId, now, deviceId))
52+
}
53+
tracker.deletedNotes.removeIf { now - it.deletedAt > Constants.TRASH_RETENTION_MS }
54+
sardine.put(url, tracker.toJson().toByteArray(Charsets.UTF_8), "application/json")
55+
Logger.d(TAG, "📝 deletions.json updated: added $noteId, ${tracker.deletedNotes.size} entries")
56+
} catch (e: Exception) {
57+
Logger.w(TAG, "appendAndUpload deletions.json for $noteId failed (non-fatal): ${e.message}")
58+
}
59+
}
60+
61+
companion object {
62+
private const val TAG = "DeletionSyncManager"
63+
const val DELETIONS_FILE_NAME = "deletions.json"
64+
}
65+
}

android/app/src/main/java/dev/dettmer/simplenotes/sync/NoteDownloader.kt

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal data class DownloadResult(
2929
val conflictCount: Int,
3030
val deletedOnServerCount: Int = 0,
3131
val folderReconciledCount: Int = 0,
32+
val trashedDownloadedCount: Int = 0,
3233
val downloadFailed: Boolean = false,
3334
val downloadError: String? = null
3435
)
@@ -90,6 +91,7 @@ internal class NoteDownloader(
9091
var conflictCount = 0
9192
var skippedDeleted = 0 // Track skipped deleted notes
9293
var folderReconciledCount = 0
94+
var trashedDownloadedCount = 0
9395
val processedIds = mutableSetOf<String>() // 🆕 v1.2.2: Track already loaded notes
9496

9597
Logger.d(TAG, "📥 downloadAll() called:")
@@ -378,7 +380,7 @@ internal class NoteDownloader(
378380
localNote == null -> {
379381
// New note from server
380382
storage.saveNote(remoteNoteFoldered.copy(syncStatus = SyncStatus.SYNCED))
381-
downloadedCount++
383+
if (remoteNote.trashedAt == null) downloadedCount++ else trashedDownloadedCount++
382384
Logger.d(TAG, " ✅ Downloaded from /$activeSyncFolderName/: ${remoteNote.id}")
383385

384386
// ⚡ Batch E-Tag for later
@@ -389,7 +391,7 @@ internal class NoteDownloader(
389391
forceOverwrite -> {
390392
// OVERWRITE mode: Always replace regardless of timestamps
391393
storage.saveNote(remoteNoteFoldered.copy(syncStatus = SyncStatus.SYNCED))
392-
downloadedCount++
394+
if (remoteNote.trashedAt == null) downloadedCount++ else trashedDownloadedCount++
393395
Logger.d(
394396
TAG,
395397
" ♻️ Overwritten from /$activeSyncFolderName/: ${remoteNote.id}"
@@ -409,7 +411,7 @@ internal class NoteDownloader(
409411
} else {
410412
// Safe to overwrite
411413
storage.saveNote(remoteNoteFoldered.copy(syncStatus = SyncStatus.SYNCED))
412-
downloadedCount++
414+
if (remoteNote.trashedAt == null) downloadedCount++ else trashedDownloadedCount++
413415
Logger.d(TAG, " ✅ Updated from /$activeSyncFolderName/: ${remoteNote.id}")
414416

415417
if (result.etag != null) {
@@ -566,13 +568,13 @@ internal class NoteDownloader(
566568
when {
567569
localNote == null -> {
568570
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
569-
downloadedCount++
571+
if (remoteNote.trashedAt == null) downloadedCount++ else trashedDownloadedCount++
570572
Logger.d(TAG, " ✅ Downloaded from ROOT: ${remoteNote.id}")
571573
}
572574
forceOverwrite -> {
573575
// OVERWRITE mode: Always replace regardless of timestamps
574576
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
575-
downloadedCount++
577+
if (remoteNote.trashedAt == null) downloadedCount++ else trashedDownloadedCount++
576578
Logger.d(TAG, " ♻️ Overwritten from ROOT: ${remoteNote.id}")
577579
}
578580
localNote.updatedAt < remoteNote.updatedAt -> {
@@ -581,7 +583,7 @@ internal class NoteDownloader(
581583
conflictCount++
582584
} else {
583585
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
584-
downloadedCount++
586+
if (remoteNote.trashedAt == null) downloadedCount++ else trashedDownloadedCount++
585587
Logger.d(TAG, " ✅ Updated from ROOT: ${remoteNote.id}")
586588
}
587589
}
@@ -623,7 +625,7 @@ internal class NoteDownloader(
623625

624626
// 🆕 v1.8.0: Server-Deletions erkennen (nach Downloads)
625627
val allLocalNotes = storage.loadAllNotes()
626-
val deletedOnServerCount = detectDeletions(serverNoteIds, allLocalNotes)
628+
val deletedOnServerCount = detectDeletions(serverNoteIds, allLocalNotes, deletionTracker)
627629

628630
if (deletedOnServerCount > 0) {
629631
Logger.d(TAG, "$deletedOnServerCount note(s) detected as deleted on server")
@@ -635,6 +637,7 @@ internal class NoteDownloader(
635637
conflictCount = conflictCount,
636638
deletedOnServerCount = deletedOnServerCount,
637639
folderReconciledCount = folderReconciledCount,
640+
trashedDownloadedCount = trashedDownloadedCount,
638641
downloadFailed = downloadException != null,
639642
downloadError = downloadException?.message
640643
)
@@ -651,7 +654,11 @@ internal class NoteDownloader(
651654
* @param localNotes Alle lokalen Notizen
652655
* @return Anzahl der als DELETED_ON_SERVER markierten Notizen
653656
*/
654-
suspend fun detectDeletions(serverNoteIds: Set<String>, localNotes: List<Note>): Int {
657+
suspend fun detectDeletions(
658+
serverNoteIds: Set<String>,
659+
localNotes: List<Note>,
660+
deletionTracker: dev.dettmer.simplenotes.models.DeletionTracker = dev.dettmer.simplenotes.models.DeletionTracker()
661+
): Int {
655662
// 🆕 v2.8.0 (Local-Only Folders): Notizen in lokalen Ordnern nie als server-gelöscht markieren.
656663
val localOnlyFolders = folderStore.getLocalOnlyFolderNames().map { it.lowercase() }.toSet()
657664
val syncedNotes = localNotes.filter {
@@ -706,15 +713,17 @@ internal class NoteDownloader(
706713
// - CONFLICT: Wird separat behandelt
707714
// - DELETED_ON_SERVER: Bereits markiert
708715
if (note.id !in serverNoteIds) {
709-
if (note.trashedAt != null) {
710-
// 🆕 v2.9.0 (Trash): Die Notiz lag bereits im Papierkorb und ist jetzt auch vom
711-
// Server verschwunden → ein anderes Gerät hat sie endgültig gelöscht (purge).
712-
// Lokal hart löschen; der DeletionTracker-Eintrag verhindert Zombie-Wiederkehr.
716+
val purgedRemotely = deletionTracker.isDeleted(note.id) &&
717+
(deletionTracker.getDeletionTimestamp(note.id) ?: 0L) >= note.updatedAt
718+
if (note.trashedAt != null || purgedRemotely) {
719+
// 🆕 v2.9.0 (Trash): already trashed + gone from server → purged on another device.
720+
// 🆕 shared ledger: id in deletions.json with deletedAt ≥ updatedAt → hard-delete.
721+
// Timestamp guard preserves a note legitimately re-created after the recorded purge.
713722
storage.deleteNote(note.id)
714723
Logger.d(
715724
TAG,
716-
"🔥 Trashed note '${note.title}' (${note.id}) purged on another device → " +
717-
"hard-deleted locally"
725+
"🔥 Note '${note.title}' (${note.id}) purged remotely → hard-deleted locally" +
726+
if (purgedRemotely && note.trashedAt == null) " (shared ledger)" else ""
718727
)
719728
} else {
720729
// 🆕 v2.9.0 (Trash): Echte Server-Löschung → in den Papierkorb verschieben.

android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncBannerHelper.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ fun buildSyncResultBanner(context: Context, result: SyncResult): String? {
1818
if (result.purgedFromServerCount > 0) {
1919
add(context.getString(R.string.sync_deleted_from_server_count, result.purgedFromServerCount))
2020
}
21+
if (result.trashedFromServerCount > 0) {
22+
add(context.getString(R.string.sync_trashed_from_server_count, result.trashedFromServerCount))
23+
}
2124
}
2225
return if (parts.isEmpty()) null else parts.joinToString(" · ")
2326
}

android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ data class SyncResult(
1313
val conflictCount: Int = 0,
1414
val deletedOnServerCount: Int = 0, // 🆕 v1.8.0
1515
val purgedFromServerCount: Int = 0, // 🆕 v2.9.x (Trash): via „Papierkorb leeren" ausgelöste Server-Löschungen
16+
val trashedFromServerCount: Int = 0, // Notizen, die vom Server bereits mit trashedAt ankamen
1617
val foldersChanged: Boolean = false, // 🆕 v2.7.0 (Folders): Ordner-Metadaten haben sich geändert
1718
val foldersReconciled: Boolean = false, // 🆕 v2.7.2: folderName einer Notiz an Server-Pfad geheilt
1819
val errorMessage: String? = null,

0 commit comments

Comments
 (0)