From 32d723fd19ec36cbfe0d7f88895b55c20cabfd47 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 21:45:41 +0000 Subject: [PATCH 1/5] Implement rename logic for conflict resolution Implemented the TODO comments for RenameLocal and RenameRemote conflict resolution strategies in SyncEngine.cs: - Added GenerateConflictName() helper method that creates conflict filenames with timestamps (e.g., "document.txt" -> "document (conflict 2025-11-05 12-34-56).txt") - RenameLocal: Moves the local file to a conflict name, then downloads the remote file to the original path - RenameRemote: Moves the remote file to a conflict name, then uploads the local file to the original path Both implementations: - Check that both local and remote items exist before proceeding - Use the existing MoveAsync() method from ISyncStorage for efficient renaming - Fallback to incrementing files conflicted if items are missing - Properly use existing UploadFileAsync/DownloadFileAsync methods to maintain consistency with other sync operations --- src/SharpSync/Sync/SyncEngine.cs | 43 +++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index da02da8..68e56e4 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -748,13 +748,33 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult break; case ConflictResolution.RenameLocal: - // TODO: Implement rename logic - create new file with modified name - result.IncrementFilesConflicted(); + if (action.LocalItem is not null && action.RemoteItem is not null) { + // Generate conflict name for local file + var conflictPath = GenerateConflictName(action.Path); + + // Move local file to conflict name + await _localStorage.MoveAsync(action.Path, conflictPath, cancellationToken); + + // Download remote file to original path + await DownloadFileAsync(action, result, cancellationToken); + } else { + result.IncrementFilesConflicted(); + } break; case ConflictResolution.RenameRemote: - // TODO: Implement rename logic - create new file with modified name - result.IncrementFilesConflicted(); + if (action.LocalItem is not null && action.RemoteItem is not null) { + // Generate conflict name for remote file + var conflictPath = GenerateConflictName(action.Path); + + // Move remote file to conflict name + await _remoteStorage.MoveAsync(action.Path, conflictPath, cancellationToken); + + // Upload local file to original path + await UploadFileAsync(action, result, cancellationToken); + } else { + result.IncrementFilesConflicted(); + } break; default: @@ -763,6 +783,21 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult } } + private static string GenerateConflictName(string path) { + // Generate a conflict filename by inserting a timestamp before the extension + // Example: "document.txt" -> "document (conflict 2025-11-05 12-34-56).txt" + var directory = Path.GetDirectoryName(path); + var fileName = Path.GetFileNameWithoutExtension(path); + var extension = Path.GetExtension(path); + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss"); + + var conflictFileName = $"{fileName} (conflict {timestamp}){extension}"; + + return string.IsNullOrEmpty(directory) + ? conflictFileName + : Path.Combine(directory, conflictFileName); + } + private async Task UpdateDatabaseStateAsync(ChangeSet changes, CancellationToken cancellationToken) { // Update database with new sync states var updates = new List(); From 9eca66b3f9b3c63d39faa695abcb167bfc41056d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 22:06:18 +0000 Subject: [PATCH 2/5] Add database tracking for conflict files during rename resolution Enhanced the rename conflict resolution logic to properly track the newly created conflict files in the sync database: RenameLocal case: - After moving local file to conflict name and downloading remote file, the conflict file is tracked in the database with status LocalNew - This ensures the conflict file will be uploaded to remote on next sync RenameRemote case: - After moving remote file to conflict name and uploading local file, the conflict file is tracked in the database with status RemoteNew - This ensures the conflict file will be downloaded to local on next sync Benefits: - Conflict files are immediately tracked in the database - Next sync will properly handle these files (upload LocalNew, download RemoteNew) - Database state accurately reflects the actual file system state - No orphaned files that aren't tracked --- src/SharpSync/Sync/SyncEngine.cs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index 68e56e4..e5230f4 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -757,6 +757,21 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult // Download remote file to original path await DownloadFileAsync(action, result, cancellationToken); + + // Track the conflict file in database (exists locally, needs to be uploaded) + var conflictItem = await _localStorage.GetItemAsync(conflictPath, cancellationToken); + if (conflictItem is not null) { + var conflictState = new SyncState { + Path = conflictPath, + IsDirectory = conflictItem.IsDirectory, + Status = SyncStatus.LocalNew, + LastSyncTime = DateTime.UtcNow, + LocalHash = conflictItem.ETag ?? await _localStorage.ComputeHashAsync(conflictPath, cancellationToken), + LocalSize = conflictItem.Size, + LocalModified = conflictItem.LastModified + }; + await _database.UpdateSyncStateAsync(conflictState, cancellationToken); + } } else { result.IncrementFilesConflicted(); } @@ -772,6 +787,21 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult // Upload local file to original path await UploadFileAsync(action, result, cancellationToken); + + // Track the conflict file in database (exists remotely, needs to be downloaded) + var conflictItem = await _remoteStorage.GetItemAsync(conflictPath, cancellationToken); + if (conflictItem is not null) { + var conflictState = new SyncState { + Path = conflictPath, + IsDirectory = conflictItem.IsDirectory, + Status = SyncStatus.RemoteNew, + LastSyncTime = DateTime.UtcNow, + RemoteHash = conflictItem.ETag ?? await _remoteStorage.ComputeHashAsync(conflictPath, cancellationToken), + RemoteSize = conflictItem.Size, + RemoteModified = conflictItem.LastModified + }; + await _database.UpdateSyncStateAsync(conflictState, cancellationToken); + } } else { result.IncrementFilesConflicted(); } From b434015ccd959fe8739acd5012d8c04c16279bb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 22:19:18 +0000 Subject: [PATCH 3/5] Use computer/domain names for conflict files instead of timestamps Changed conflict file naming from timestamps to computer/domain names for better identification of file sources: - RenameLocal: Uses Environment.MachineName for conflict files Example: "document.txt" -> "document (andre-vivobook).txt" - RenameRemote: Uses domain name extracted from remote storage URL Example: "document.txt" -> "document (disk.cx).txt" Added GetDomainFromUrl() helper method to extract domain from URLs with fallback to "remote" if URL parsing fails. Benefits: - Users can immediately identify which machine/server each version came from - More intuitive than timestamps for multi-device synchronization - Clearer conflict resolution for users managing files across devices - Aligns with common sync tools like Nextcloud, Dropbox, etc. --- src/SharpSync/Sync/SyncEngine.cs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index e5230f4..2e146e0 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -749,8 +749,8 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult case ConflictResolution.RenameLocal: if (action.LocalItem is not null && action.RemoteItem is not null) { - // Generate conflict name for local file - var conflictPath = GenerateConflictName(action.Path); + // Generate conflict name for local file using computer name + var conflictPath = GenerateConflictName(action.Path, Environment.MachineName); // Move local file to conflict name await _localStorage.MoveAsync(action.Path, conflictPath, cancellationToken); @@ -779,8 +779,8 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult case ConflictResolution.RenameRemote: if (action.LocalItem is not null && action.RemoteItem is not null) { - // Generate conflict name for remote file - var conflictPath = GenerateConflictName(action.Path); + // Generate conflict name for remote file using domain name + var conflictPath = GenerateConflictName(action.Path, GetDomainFromUrl(_remoteStorage.RootPath)); // Move remote file to conflict name await _remoteStorage.MoveAsync(action.Path, conflictPath, cancellationToken); @@ -813,21 +813,33 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult } } - private static string GenerateConflictName(string path) { - // Generate a conflict filename by inserting a timestamp before the extension - // Example: "document.txt" -> "document (conflict 2025-11-05 12-34-56).txt" + private static string GenerateConflictName(string path, string sourceIdentifier) { + // Generate a conflict filename by inserting the source identifier before the extension + // Example: "document.txt" -> "document (andre-vivobook).txt" for local + // "document.txt" -> "document (disk.cx).txt" for remote var directory = Path.GetDirectoryName(path); var fileName = Path.GetFileNameWithoutExtension(path); var extension = Path.GetExtension(path); - var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss"); - var conflictFileName = $"{fileName} (conflict {timestamp}){extension}"; + var conflictFileName = $"{fileName} ({sourceIdentifier}){extension}"; return string.IsNullOrEmpty(directory) ? conflictFileName : Path.Combine(directory, conflictFileName); } + private static string GetDomainFromUrl(string url) { + // Extract domain name from URL + // Example: "https://disk.cx/remote.php/dav/files/user/" -> "disk.cx" + try { + var uri = new Uri(url); + return uri.Host; + } catch { + // Fallback to "remote" if URL parsing fails + return "remote"; + } + } + private async Task UpdateDatabaseStateAsync(ChangeSet changes, CancellationToken cancellationToken) { // Update database with new sync states var updates = new List(); From 2711b7b613e9d67f7735a41e284175f3b8c074e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 22:24:58 +0000 Subject: [PATCH 4/5] Add collision detection for multiple conflicts from same source Enhanced conflict file naming to handle multiple conflicts from the same machine or domain by appending numbered suffixes. Renamed GenerateConflictName() to GenerateUniqueConflictNameAsync() which: - Checks if base conflict name exists using storage.ExistsAsync() - If exists, tries numbered versions: (source 2), (source 3), etc. - Supports up to 100 conflicts from the same source - Falls back to timestamp if 100+ conflicts exist Examples: - First conflict: "document (andre-vivobook).txt" - Second conflict: "document (andre-vivobook 2).txt" - Third conflict: "document (andre-vivobook 3).txt" Or for remote: - First conflict: "document (disk.cx).txt" - Second conflict: "document (disk.cx 2).txt" This prevents: - MoveAsync failures due to existing conflict files - Data loss from overwriting previous conflict files - Database inconsistencies Both RenameLocal and RenameRemote now use this collision-safe method. --- src/SharpSync/Sync/SyncEngine.cs | 43 ++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index 2e146e0..8a18ae6 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -749,8 +749,8 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult case ConflictResolution.RenameLocal: if (action.LocalItem is not null && action.RemoteItem is not null) { - // Generate conflict name for local file using computer name - var conflictPath = GenerateConflictName(action.Path, Environment.MachineName); + // Generate unique conflict name for local file using computer name + var conflictPath = await GenerateUniqueConflictNameAsync(action.Path, Environment.MachineName, _localStorage, cancellationToken); // Move local file to conflict name await _localStorage.MoveAsync(action.Path, conflictPath, cancellationToken); @@ -779,8 +779,8 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult case ConflictResolution.RenameRemote: if (action.LocalItem is not null && action.RemoteItem is not null) { - // Generate conflict name for remote file using domain name - var conflictPath = GenerateConflictName(action.Path, GetDomainFromUrl(_remoteStorage.RootPath)); + // Generate unique conflict name for remote file using domain name + var conflictPath = await GenerateUniqueConflictNameAsync(action.Path, GetDomainFromUrl(_remoteStorage.RootPath), _remoteStorage, cancellationToken); // Move remote file to conflict name await _remoteStorage.MoveAsync(action.Path, conflictPath, cancellationToken); @@ -813,16 +813,43 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult } } - private static string GenerateConflictName(string path, string sourceIdentifier) { - // Generate a conflict filename by inserting the source identifier before the extension - // Example: "document.txt" -> "document (andre-vivobook).txt" for local - // "document.txt" -> "document (disk.cx).txt" for remote + private async Task GenerateUniqueConflictNameAsync(string path, string sourceIdentifier, ISyncStorage storage, CancellationToken cancellationToken) { + // Generate a unique conflict filename by inserting the source identifier before the extension + // If a conflict with the same name already exists, append a number + // Examples: + // "document.txt" -> "document (andre-vivobook).txt" + // If exists -> "document (andre-vivobook 2).txt" + // If exists -> "document (andre-vivobook 3).txt" var directory = Path.GetDirectoryName(path); var fileName = Path.GetFileNameWithoutExtension(path); var extension = Path.GetExtension(path); + // Try base name first var conflictFileName = $"{fileName} ({sourceIdentifier}){extension}"; + var conflictPath = string.IsNullOrEmpty(directory) + ? conflictFileName + : Path.Combine(directory, conflictFileName); + + // Check if this path already exists + if (!await storage.ExistsAsync(conflictPath, cancellationToken)) { + return conflictPath; + } + + // If it exists, try numbered versions + for (int i = 2; i <= 100; i++) { + conflictFileName = $"{fileName} ({sourceIdentifier} {i}){extension}"; + conflictPath = string.IsNullOrEmpty(directory) + ? conflictFileName + : Path.Combine(directory, conflictFileName); + + if (!await storage.ExistsAsync(conflictPath, cancellationToken)) { + return conflictPath; + } + } + // Fallback: use timestamp if we somehow have 100+ conflicts + var timestamp = DateTime.Now.ToString("yyyy-MM-dd-HHmmss"); + conflictFileName = $"{fileName} ({sourceIdentifier} {timestamp}){extension}"; return string.IsNullOrEmpty(directory) ? conflictFileName : Path.Combine(directory, conflictFileName); From 085dec51bd5257b8e0b6a98d5f2208eee73abb49 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 22:56:33 +0000 Subject: [PATCH 5/5] Fix CA1822: Mark GenerateUniqueConflictNameAsync as static The method doesn't access any instance data, so marking it as static resolves the code analysis warning CA1822. --- src/SharpSync/Sync/SyncEngine.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index 8a18ae6..bd14bf3 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -813,7 +813,7 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult } } - private async Task GenerateUniqueConflictNameAsync(string path, string sourceIdentifier, ISyncStorage storage, CancellationToken cancellationToken) { + private static async Task GenerateUniqueConflictNameAsync(string path, string sourceIdentifier, ISyncStorage storage, CancellationToken cancellationToken) { // Generate a unique conflict filename by inserting the source identifier before the extension // If a conflict with the same name already exists, append a number // Examples: