From c15793b67cd09839b8dc4c0c1956d7435ac07b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Polykanine?= Date: Sun, 2 Nov 2025 01:53:50 +0100 Subject: [PATCH] Modernize code --- src/SharpSync/Database/SqliteSyncDatabase.cs | 61 +++++- src/SharpSync/SharpSync.csproj | 2 + src/SharpSync/Storage/LocalFileStorage.cs | 9 +- src/SharpSync/Storage/WebDavStorage.cs | 3 +- src/SharpSync/Sync/SyncEngine.cs | 214 +++++++++++-------- 5 files changed, 193 insertions(+), 96 deletions(-) diff --git a/src/SharpSync/Database/SqliteSyncDatabase.cs b/src/SharpSync/Database/SqliteSyncDatabase.cs index bbbbfa5..44bf36a 100644 --- a/src/SharpSync/Database/SqliteSyncDatabase.cs +++ b/src/SharpSync/Database/SqliteSyncDatabase.cs @@ -4,33 +4,61 @@ namespace Oire.SharpSync.Database; /// -/// SQLite implementation of the sync database +/// SQLite implementation of the sync database for tracking file synchronization state. /// +/// +/// This class provides persistent storage for sync state using SQLite. It tracks file metadata, +/// sync status, and conflict information. The database is automatically created if it doesn't exist. +/// public class SqliteSyncDatabase: ISyncDatabase { private readonly string _databasePath; private SQLiteAsyncConnection? _connection; private bool _disposed; + /// + /// Initializes a new instance of the class. + /// + /// The full path to the SQLite database file. + /// Thrown when databasePath is null. public SqliteSyncDatabase(string databasePath) { - _databasePath = databasePath ?? throw new ArgumentNullException(nameof(databasePath)); + ArgumentNullException.ThrowIfNull(databasePath); + _databasePath = databasePath; } + /// + /// Initializes the database connection and creates necessary tables and indexes. + /// + /// Cancellation token to cancel the operation. + /// + /// This method must be called before using any other database operations. + /// It creates the directory if it doesn't exist and sets up the database schema. + /// public async Task InitializeAsync(CancellationToken cancellationToken = default) { var directory = Path.GetDirectoryName(_databasePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); + } _connection = new SQLiteAsyncConnection(_databasePath); await _connection.CreateTableAsync(); - await _connection.ExecuteAsync(@" + await _connection.ExecuteAsync(""" CREATE INDEX IF NOT EXISTS idx_syncstates_status - ON SyncStates(Status)"); - await _connection.ExecuteAsync(@" + ON SyncStates(Status) + """); + await _connection.ExecuteAsync(""" CREATE INDEX IF NOT EXISTS idx_syncstates_lastsync - ON SyncStates(LastSyncTime)"); + ON SyncStates(LastSyncTime) + """); } + /// + /// Gets the synchronization state for a specific file path. + /// + /// The file path to look up. + /// Cancellation token to cancel the operation. + /// The sync state if found; otherwise, null. + /// Thrown when the database is not initialized. public async Task GetSyncStateAsync(string path, CancellationToken cancellationToken = default) { EnsureInitialized(); return await _connection!.Table() @@ -38,6 +66,16 @@ CREATE INDEX IF NOT EXISTS idx_syncstates_lastsync .FirstOrDefaultAsync(); } + /// + /// Updates or inserts a synchronization state record. + /// + /// The sync state to update or insert. + /// Cancellation token to cancel the operation. + /// + /// If the state has Id = 0, it will be inserted as a new record. + /// Otherwise, the existing record will be updated. + /// + /// Thrown when the database is not initialized. public async Task UpdateSyncStateAsync(SyncState state, CancellationToken cancellationToken = default) { EnsureInitialized(); @@ -67,6 +105,12 @@ public async Task> GetPendingSyncStatesAsync(Cancellation .ToListAsync(); } + /// + /// Begins a database transaction for atomic operations. + /// + /// Cancellation token to cancel the operation. + /// A transaction object that can be used to commit or rollback changes. + /// Thrown when the database is not initialized. public async Task BeginTransactionAsync(CancellationToken cancellationToken = default) { EnsureInitialized(); await Task.CompletedTask; // This method returns immediately but needs to be async for interface consistency @@ -115,8 +159,9 @@ public async Task GetStatsAsync(CancellationToken cancellationTok } private void EnsureInitialized() { - if (_connection == null) + if (_connection is null) { throw new InvalidOperationException("Database not initialized. Call InitializeAsync first."); + } } public void Dispose() { diff --git a/src/SharpSync/SharpSync.csproj b/src/SharpSync/SharpSync.csproj index 6cefd52..1eb4829 100644 --- a/src/SharpSync/SharpSync.csproj +++ b/src/SharpSync/SharpSync.csproj @@ -25,6 +25,8 @@ file-sync;synchronization;webdav;sftp;nextcloud;owncloud;backup;sync 1.0.0 false + + $(NoWarn);NETSDK1206 diff --git a/src/SharpSync/Storage/LocalFileStorage.cs b/src/SharpSync/Storage/LocalFileStorage.cs index 6412363..fc7f8d5 100644 --- a/src/SharpSync/Storage/LocalFileStorage.cs +++ b/src/SharpSync/Storage/LocalFileStorage.cs @@ -13,21 +13,24 @@ public class LocalFileStorage: ISyncStorage { public string RootPath => _rootPath; public LocalFileStorage(string rootPath) { - if (string.IsNullOrWhiteSpace(rootPath)) + if (string.IsNullOrWhiteSpace(rootPath)) { throw new ArgumentException("Root path cannot be empty", nameof(rootPath)); + } _rootPath = Path.GetFullPath(rootPath); - if (!Directory.Exists(_rootPath)) + if (!Directory.Exists(_rootPath)) { throw new DirectoryNotFoundException($"Root path does not exist: {_rootPath}"); + } } public async Task> ListItemsAsync(string path, CancellationToken cancellationToken = default) { var fullPath = GetFullPath(path); var items = new List(); - if (!Directory.Exists(fullPath)) + if (!Directory.Exists(fullPath)) { return items; + } // Get directories foreach (var dir in Directory.EnumerateDirectories(fullPath)) { diff --git a/src/SharpSync/Storage/WebDavStorage.cs b/src/SharpSync/Storage/WebDavStorage.cs index 5e18ead..e97e1e5 100644 --- a/src/SharpSync/Storage/WebDavStorage.cs +++ b/src/SharpSync/Storage/WebDavStorage.cs @@ -55,8 +55,9 @@ public WebDavStorage( int chunkSizeBytes = 10 * 1024 * 1024, // 10MB int maxRetries = 3, int timeoutSeconds = 300) { - if (string.IsNullOrWhiteSpace(baseUrl)) + if (string.IsNullOrWhiteSpace(baseUrl)) { throw new ArgumentException("Base URL cannot be empty", nameof(baseUrl)); + } _baseUrl = baseUrl.TrimEnd('/'); _rootPath = rootPath.Trim('/'); diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index b7428cd..d4d6dda 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -5,7 +5,7 @@ namespace Oire.SharpSync.Sync; /// -/// Production-ready sync engine with incremental sync, change detection, and parallel processing +/// Sync engine with incremental sync, change detection, and parallel processing /// Optimized for large file sets and efficient synchronization /// public class SyncEngine: ISyncEngine { @@ -59,12 +59,19 @@ public SyncEngine( IConflictResolver conflictResolver, int maxParallelism = 4, bool useChecksums = false, - TimeSpan? changeDetectionWindow = null) { - _localStorage = localStorage ?? throw new ArgumentNullException(nameof(localStorage)); - _remoteStorage = remoteStorage ?? throw new ArgumentNullException(nameof(remoteStorage)); - _database = database ?? throw new ArgumentNullException(nameof(database)); - _filter = filter ?? throw new ArgumentNullException(nameof(filter)); - _conflictResolver = conflictResolver ?? throw new ArgumentNullException(nameof(conflictResolver)); + TimeSpan? changeDetectionWindow = null + ) { + ArgumentNullException.ThrowIfNull(localStorage); + ArgumentNullException.ThrowIfNull(remoteStorage); + ArgumentNullException.ThrowIfNull(database); + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(conflictResolver); + + _localStorage = localStorage; + _remoteStorage = remoteStorage; + _database = database; + _filter = filter; + _conflictResolver = conflictResolver; _maxParallelism = Math.Max(1, maxParallelism); _useChecksums = useChecksums; @@ -86,60 +93,68 @@ public SyncEngine( } /// - /// Performs incremental synchronization + /// Performs incremental synchronization between local and remote storage. /// + /// Optional synchronization options including dry run mode and conflict resolution settings. + /// Cancellation token to cancel the operation. + /// A containing synchronization statistics and any errors that occurred. + /// Thrown when the sync engine has been disposed. + /// Thrown when synchronization is already in progress. + /// Thrown when the operation is cancelled. public async Task SynchronizeAsync(SyncOptions? options = null, CancellationToken cancellationToken = default) { - if (_disposed) + if (_disposed) { throw new ObjectDisposedException(nameof(SyncEngine)); + } - if (!await _syncSemaphore.WaitAsync(0, cancellationToken)) + if (!await _syncSemaphore.WaitAsync(0, cancellationToken)) { throw new InvalidOperationException("Synchronization is already in progress"); + } try { - using (_currentSyncCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) { - var syncToken = _currentSyncCts.Token; - var result = new SyncResult(); - var sw = Stopwatch.StartNew(); - - try { - // Phase 1: Fast change detection - RaiseProgress(new SyncProgress { CurrentItem = "Detecting changes..." }, SyncOperation.Scanning); - var changes = await DetectChangesAsync(options, syncToken); - - if (changes.TotalChanges == 0) { - result.Success = true; - result.Details = "No changes detected"; - return result; - } + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _currentSyncCts = linkedCts; + var syncToken = linkedCts.Token; + var result = new SyncResult(); + var sw = Stopwatch.StartNew(); - // Phase 2: Process changes (respecting dry run mode) - RaiseProgress(new SyncProgress { TotalItems = changes.TotalChanges }, SyncOperation.Unknown); + try { + // Phase 1: Fast change detection + RaiseProgress(new SyncProgress { CurrentItem = "Detecting changes..." }, SyncOperation.Scanning); + var changes = await DetectChangesAsync(options, syncToken); - if (options?.DryRun != true) { - await ProcessChangesAsync(changes, options, result, syncToken); + if (changes.TotalChanges == 0) { + result.Success = true; + result.Details = "No changes detected"; + return result; + } - // Phase 3: Update database state - await UpdateDatabaseStateAsync(changes, syncToken); - } else { - // Dry run - just count what would be done - result.FilesSynchronized = changes.Additions.Count + changes.Modifications.Count; - result.FilesDeleted = changes.Deletions.Count; - result.Details = $"Dry run: Would sync {result.FilesSynchronized} files, delete {result.FilesDeleted}"; - } + // Phase 2: Process changes (respecting dry run mode) + RaiseProgress(new SyncProgress { TotalItems = changes.TotalChanges }, SyncOperation.Unknown); - result.Success = true; - } catch (OperationCanceledException) { - result.Error = new InvalidOperationException("Synchronization was cancelled"); - throw; - } catch (Exception ex) { - result.Error = ex; - result.Details = ex.Message; - } finally { - result.ElapsedTime = sw.Elapsed; + if (options?.DryRun != true) { + await ProcessChangesAsync(changes, options, result, syncToken); + + // Phase 3: Update database state + await UpdateDatabaseStateAsync(changes, syncToken); + } else { + // Dry run - just count what would be done + result.FilesSynchronized = changes.Additions.Count + changes.Modifications.Count; + result.FilesDeleted = changes.Deletions.Count; + result.Details = $"Dry run: Would sync {result.FilesSynchronized} files, delete {result.FilesDeleted}"; } - return result; + result.Success = true; + } catch (OperationCanceledException) { + result.Error = new InvalidOperationException("Synchronization was cancelled"); + throw; + } catch (Exception ex) { + result.Error = ex; + result.Details = ex.Message; + } finally { + result.ElapsedTime = sw.Elapsed; } + + return result; } finally { _currentSyncCts = null; _syncSemaphore.Release(); @@ -147,8 +162,15 @@ public async Task SynchronizeAsync(SyncOptions? options = null, Canc } /// - /// Preview what would be synchronized without making changes + /// Previews what would be synchronized without making any actual changes (dry run mode). /// + /// Optional synchronization options. DryRun will be forced to true. + /// Cancellation token to cancel the operation. + /// A containing information about what changes would be made. + /// + /// This method is useful for showing users what changes will be made before actually synchronizing. + /// It performs full change detection but does not modify any files or the sync state database. + /// public async Task PreviewSyncAsync(SyncOptions? options = null, CancellationToken cancellationToken = default) { var previewOptions = options?.Clone() ?? new SyncOptions(); previewOptions.DryRun = true; @@ -210,8 +232,9 @@ private async Task ScanDirectoryRecursiveAsync( var tasks = new List(); foreach (var item in items) { - if (!_filter.ShouldSync(item.Path)) + if (!_filter.ShouldSync(item.Path)) { continue; + } changeSet.ProcessedPaths.Add(item.Path); @@ -256,20 +279,24 @@ private async Task ScanDirectoryRecursiveAsync( private async Task HasChangedAsync(ISyncStorage storage, SyncItem item, SyncState tracked, bool isLocal, CancellationToken cancellationToken) { if (isLocal) { // Check local changes - if (tracked.LocalModified == null) + if (tracked.LocalModified is null) { return true; // Was deleted, now exists + } // Use ETag if available (fast) - if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.LocalHash) + if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.LocalHash) { return false; + } // Check modification time (considering detection window) - if (Math.Abs((item.LastModified - tracked.LocalModified.Value).TotalMilliseconds) > _changeDetectionWindow.TotalMilliseconds) + if (Math.Abs((item.LastModified - tracked.LocalModified.Value).TotalMilliseconds) > _changeDetectionWindow.TotalMilliseconds) { return true; + } // Check size - if (item.Size != tracked.LocalSize) + if (item.Size != tracked.LocalSize) { return true; + } // If using checksums, compute and compare if (_useChecksums && !item.IsDirectory) { @@ -280,20 +307,24 @@ private async Task HasChangedAsync(ISyncStorage storage, SyncItem item, Sy return false; } else { // Check remote changes - if (tracked.RemoteModified == null) + if (tracked.RemoteModified is null) { return true; // Was deleted, now exists + } // Use ETag if available (fast) - if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.RemoteHash) + if (!string.IsNullOrEmpty(item.ETag) && item.ETag == tracked.RemoteHash) { return false; + } // Check modification time - if (Math.Abs((item.LastModified - tracked.RemoteModified.Value).TotalMilliseconds) > _changeDetectionWindow.TotalMilliseconds) + if (Math.Abs((item.LastModified - tracked.RemoteModified.Value).TotalMilliseconds) > _changeDetectionWindow.TotalMilliseconds) { return true; + } // Check size - if (item.Size != tracked.RemoteSize) + if (item.Size != tracked.RemoteSize) { return true; + } // If using checksums, compute and compare if (_useChecksums && !item.IsDirectory) { @@ -384,7 +415,7 @@ private static ActionGroups AnalyzeAndPrioritizeChanges(ChangeSet changes) { } var action = CreateDeletionAction(deletion); - if (action != null) { + if (action is not null) { groups.Deletes.Add(action); } } @@ -412,8 +443,9 @@ private static int CalculatePriority(SyncItem item) { int priority = 0; // Directories first - if (item.IsDirectory) + if (item.IsDirectory) { priority += 1000; + } // Smaller files first (within same category) priority += (int)(1000000 - Math.Min(item.Size / 1024, 999999)); @@ -473,8 +505,9 @@ private async Task ProcessPhase1_DirectoriesAndSmallFilesAsync( CancellationToken cancellationToken) { var allSmallActions = actionGroups.Directories.Concat(actionGroups.SmallFiles).ToList(); - if (allSmallActions.Count == 0) + if (allSmallActions.Count == 0) { return; + } // Use high parallelism for small operations var parallelOptions = new ParallelOptions { @@ -513,11 +546,12 @@ private async Task ProcessPhase2_LargeFilesAsync( ProgressCounter progressCounter, int totalChanges, CancellationToken cancellationToken) { - if (actionGroups.LargeFiles.Count == 0) + if (actionGroups.LargeFiles.Count == 0) { return; + } // Use reduced parallelism for large files to avoid bandwidth saturation - var semaphore = new SemaphoreSlim(Math.Max(1, _maxParallelism / 2), Math.Max(1, _maxParallelism / 2)); + using var semaphore = new SemaphoreSlim(Math.Max(1, _maxParallelism / 2), Math.Max(1, _maxParallelism / 2)); var tasks = new List(); foreach (var action in actionGroups.LargeFiles) { @@ -525,7 +559,6 @@ private async Task ProcessPhase2_LargeFilesAsync( } await Task.WhenAll(tasks); - semaphore.Dispose(); } private async Task ProcessLargeFileAsync( @@ -691,13 +724,15 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult // Apply resolution switch (resolution) { case ConflictResolution.UseLocal: - if (action.LocalItem != null) + if (action.LocalItem is not null) { await UploadFileAsync(action, result, cancellationToken); + } break; case ConflictResolution.UseRemote: - if (action.RemoteItem != null) + if (action.RemoteItem is not null) { await DownloadFileAsync(action, result, cancellationToken); + } break; case ConflictResolution.Skip: @@ -705,12 +740,12 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult break; case ConflictResolution.RenameLocal: - // TODO: Implement rename logic + // TODO: Implement rename logic - create new file with modified name result.IncrementFilesConflicted(); break; case ConflictResolution.RenameRemote: - // TODO: Implement rename logic + // TODO: Implement rename logic - create new file with modified name result.IncrementFilesConflicted(); break; @@ -791,24 +826,35 @@ private async Task UpdateDatabaseStateAsync(ChangeSet changes, CancellationToken await Task.WhenAll(updates); } - private static SyncOperation GetOperationType(SyncActionType actionType) { - return actionType switch { - SyncActionType.Download => SyncOperation.Downloading, - SyncActionType.Upload => SyncOperation.Uploading, - SyncActionType.DeleteLocal or SyncActionType.DeleteRemote => SyncOperation.Deleting, - SyncActionType.Conflict => SyncOperation.ResolvingConflict, - _ => SyncOperation.Unknown - }; - } + private static SyncOperation GetOperationType(SyncActionType actionType) => actionType switch { + SyncActionType.Download => SyncOperation.Downloading, + SyncActionType.Upload => SyncOperation.Uploading, + SyncActionType.DeleteLocal or SyncActionType.DeleteRemote => SyncOperation.Deleting, + SyncActionType.Conflict => SyncOperation.ResolvingConflict, + _ => SyncOperation.Unknown + }; private void RaiseProgress(SyncProgress progress, SyncOperation operation) { ProgressChanged?.Invoke(this, new SyncProgressEventArgs(progress, progress.CurrentItem, operation)); } + /// + /// Gets synchronization database statistics including file counts and sizes. + /// + /// Cancellation token to cancel the operation. + /// Database statistics including total files, directories, and sizes. public async Task GetStatsAsync(CancellationToken cancellationToken = default) { return await _database.GetStatsAsync(cancellationToken); } + /// + /// Resets the synchronization state by clearing all tracked file information from the database. + /// + /// Cancellation token to cancel the operation. + /// + /// Use this method with caution as it will force a full resynchronization on the next sync operation. + /// This is useful when the sync state becomes corrupted or when starting fresh. + /// public async Task ResetSyncStateAsync(CancellationToken cancellationToken = default) { await _database.ClearAsync(cancellationToken); } @@ -825,9 +871,9 @@ public void Dispose() { #region Change Tracking Types internal sealed class ChangeSet { - public List Additions { get; } = new(); - public List Modifications { get; } = new(); - public List Deletions { get; } = new(); + public List Additions { get; } = []; + public List Modifications { get; } = []; + public List Deletions { get; } = []; public HashSet ProcessedPaths { get; } = new(StringComparer.OrdinalIgnoreCase); public int TotalChanges => Additions.Count + Modifications.Count + Deletions.Count; @@ -878,11 +924,11 @@ internal sealed class SyncAction { /// Organizes sync actions into optimized processing groups /// internal sealed class ActionGroups { - public List Directories { get; } = new(); - public List SmallFiles { get; } = new(); - public List LargeFiles { get; } = new(); - public List Deletes { get; } = new(); - public List Conflicts { get; } = new(); + public List Directories { get; } = []; + public List SmallFiles { get; } = []; + public List LargeFiles { get; } = []; + public List Deletes { get; } = []; + public List Conflicts { get; } = []; public void SortByPriority() { Directories.Sort((a, b) => b.Priority.CompareTo(a.Priority));