Skip to content
13 changes: 13 additions & 0 deletions src/SharpSync/Core/ISyncEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ public interface ISyncEngine: IDisposable {
/// </summary>
Task<SyncResult> PreviewSyncAsync(SyncOptions? options = null, CancellationToken cancellationToken = default);

/// <summary>
/// Gets a detailed plan of synchronization actions that will be performed
/// </summary>
/// <param name="options">Optional synchronization options</param>
/// <param name="cancellationToken">Cancellation token to cancel the operation</param>
/// <returns>A detailed sync plan with all planned actions</returns>
/// <remarks>
/// This method performs change detection and returns a detailed plan of what will happen during synchronization,
/// without actually modifying any files. Desktop clients can use this to show users a detailed preview with
/// file-by-file information, sizes, and action types before synchronization begins.
/// </remarks>
Task<SyncPlan> GetSyncPlanAsync(SyncOptions? options = null, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the current sync statistics
/// </summary>
Expand Down
35 changes: 35 additions & 0 deletions src/SharpSync/Core/SyncActionType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace Oire.SharpSync.Core;

/// <summary>
/// Defines the types of synchronization actions that can be performed on files and directories.
/// </summary>
/// <remarks>
/// These action types are used in sync plans to indicate what operation will be performed on each item.
/// Desktop clients can use this information to show users detailed previews before synchronization.
/// </remarks>
public enum SyncActionType {
/// <summary>
/// Download a file or directory from remote storage to local storage
/// </summary>
Download,

/// <summary>
/// Upload a file or directory from local storage to remote storage
/// </summary>
Upload,

/// <summary>
/// Delete a file or directory from local storage
/// </summary>
DeleteLocal,

/// <summary>
/// Delete a file or directory from remote storage
/// </summary>
DeleteRemote,

/// <summary>
/// A conflict exists and requires resolution
/// </summary>
Conflict
}
138 changes: 138 additions & 0 deletions src/SharpSync/Core/SyncPlan.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
namespace Oire.SharpSync.Core;

/// <summary>
/// Represents a detailed plan of synchronization actions that will be performed.
/// </summary>
/// <remarks>
/// This class provides desktop clients with comprehensive information about what will happen
/// during synchronization, allowing users to review and understand changes before they are applied.
/// The plan groups actions by type for easier presentation in UI.
/// </remarks>
public sealed class SyncPlan {
/// <summary>
/// Gets all planned actions, sorted by priority (highest first)
/// </summary>
public IReadOnlyList<SyncPlanAction> Actions { get; init; } = Array.Empty<SyncPlanAction>();

/// <summary>
/// Gets actions that will download files or directories from remote to local
/// </summary>
public IReadOnlyList<SyncPlanAction> Downloads => Actions.Where(a => a.ActionType == SyncActionType.Download).ToList();

/// <summary>
/// Gets actions that will upload files or directories from local to remote
/// </summary>
public IReadOnlyList<SyncPlanAction> Uploads => Actions.Where(a => a.ActionType == SyncActionType.Upload).ToList();

/// <summary>
/// Gets actions that will delete files or directories from local storage
/// </summary>
public IReadOnlyList<SyncPlanAction> LocalDeletes => Actions.Where(a => a.ActionType == SyncActionType.DeleteLocal).ToList();

/// <summary>
/// Gets actions that will delete files or directories from remote storage
/// </summary>
public IReadOnlyList<SyncPlanAction> RemoteDeletes => Actions.Where(a => a.ActionType == SyncActionType.DeleteRemote).ToList();

/// <summary>
/// Gets actions representing conflicts that need resolution
/// </summary>
public IReadOnlyList<SyncPlanAction> Conflicts => Actions.Where(a => a.ActionType == SyncActionType.Conflict).ToList();

/// <summary>
/// Gets the total number of planned actions
/// </summary>
public int TotalActions => Actions.Count;

/// <summary>
/// Gets the total number of files that will be downloaded
/// </summary>
public int DownloadCount => Downloads.Count;

/// <summary>
/// Gets the total number of files that will be uploaded
/// </summary>
public int UploadCount => Uploads.Count;

/// <summary>
/// Gets the total number of deletions (both local and remote)
/// </summary>
public int DeleteCount => LocalDeletes.Count + RemoteDeletes.Count;

/// <summary>
/// Gets the total number of conflicts
/// </summary>
public int ConflictCount => Conflicts.Count;

/// <summary>
/// Gets the total size of data that will be downloaded (in bytes)
/// </summary>
public long TotalDownloadSize => Downloads.Where(a => !a.IsDirectory).Sum(a => a.Size);

/// <summary>
/// Gets the total size of data that will be uploaded (in bytes)
/// </summary>
public long TotalUploadSize => Uploads.Where(a => !a.IsDirectory).Sum(a => a.Size);

/// <summary>
/// Gets whether this plan has any actions to perform
/// </summary>
public bool HasChanges => TotalActions > 0;

/// <summary>
/// Gets whether this plan contains any conflicts
/// </summary>
public bool HasConflicts => ConflictCount > 0;

/// <summary>
/// Gets a human-readable summary of this plan
/// </summary>
/// <remarks>
/// Example: "3 downloads (2.5 MB), 2 uploads (1.2 MB), 1 delete"
/// </remarks>
public string Summary {
get {
if (!HasChanges) {
return "No changes to synchronize";
}

var parts = new List<string>();

if (DownloadCount > 0) {
parts.Add($"{DownloadCount} download{(DownloadCount == 1 ? "" : "s")}" +
(TotalDownloadSize > 0 ? $" ({FormatSize(TotalDownloadSize)})" : ""));
}

if (UploadCount > 0) {
parts.Add($"{UploadCount} upload{(UploadCount == 1 ? "" : "s")}" +
(TotalUploadSize > 0 ? $" ({FormatSize(TotalUploadSize)})" : ""));
}

if (DeleteCount > 0) {
parts.Add($"{DeleteCount} delete{(DeleteCount == 1 ? "" : "s")}");
}

if (ConflictCount > 0) {
parts.Add($"{ConflictCount} conflict{(ConflictCount == 1 ? "" : "s")}");
}

return string.Join(", ", parts);
}
}

private static string FormatSize(long bytes) {
if (bytes < 1024) {
return $"{bytes} B";
}

if (bytes < 1024 * 1024) {
return $"{bytes / 1024.0:F1} KB";
}

if (bytes < 1024 * 1024 * 1024) {
return $"{bytes / (1024.0 * 1024.0):F1} MB";
}

return $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB";
}
}
87 changes: 87 additions & 0 deletions src/SharpSync/Core/SyncPlanAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
namespace Oire.SharpSync.Core;

/// <summary>
/// Represents a planned synchronization action that will be performed on a file or directory.
/// </summary>
/// <remarks>
/// This class is designed for desktop clients to display detailed sync previews to users before
/// synchronization begins. It provides all the information needed to show what will happen to each file.
/// </remarks>
public sealed class SyncPlanAction {
/// <summary>
/// Gets the type of synchronization action (download, upload, delete, etc.)
/// </summary>
public SyncActionType ActionType { get; init; }

/// <summary>
/// Gets the relative path of the file or directory affected by this action
/// </summary>
public string Path { get; init; } = string.Empty;

/// <summary>
/// Gets whether this action affects a directory (true) or a file (false)
/// </summary>
public bool IsDirectory { get; init; }

/// <summary>
/// Gets the size of the file in bytes (0 for directories or if unknown)
/// </summary>
public long Size { get; init; }

/// <summary>
/// Gets the last modification time of the file or directory (if available)
/// </summary>
public DateTime? LastModified { get; init; }

/// <summary>
/// Gets the type of conflict if this action represents a conflict, otherwise null
/// </summary>
public ConflictType? ConflictType { get; init; }

/// <summary>
/// Gets a human-readable description of this action
/// </summary>
/// <remarks>
/// Examples: "Download document.pdf (1.2 MB)", "Upload Photos/ folder", "Delete old-file.txt from remote"
/// </remarks>
public string Description {
get {
var sizeStr = IsDirectory ? "folder" : FormatSize(Size);
var pathDisplay = IsDirectory ? $"{Path}/" : Path;

return ActionType switch {
SyncActionType.Download => $"Download {pathDisplay}" + (IsDirectory ? "" : $" ({sizeStr})"),
SyncActionType.Upload => $"Upload {pathDisplay}" + (IsDirectory ? "" : $" ({sizeStr})"),
SyncActionType.DeleteLocal => $"Delete {pathDisplay} from local storage",
SyncActionType.DeleteRemote => $"Delete {pathDisplay} from remote storage",
SyncActionType.Conflict => $"Resolve conflict for {pathDisplay}" + (ConflictType.HasValue ? $" ({ConflictType.Value})" : ""),
_ => $"Process {pathDisplay}"
};
}
}

/// <summary>
/// Gets the priority of this action (higher number = higher priority)
/// </summary>
/// <remarks>
/// Used internally for optimization. Desktop clients can use this to sort actions
/// in a way that matches the actual synchronization order.
/// </remarks>
public int Priority { get; init; }

private static string FormatSize(long bytes) {
if (bytes < 1024) {
return $"{bytes} B";
}

if (bytes < 1024 * 1024) {
return $"{bytes / 1024.0:F1} KB";
}

if (bytes < 1024 * 1024 * 1024) {
return $"{bytes / (1024.0 * 1024.0):F1} MB";
}

return $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB";
}
}
22 changes: 22 additions & 0 deletions src/SharpSync/Sync/ActionGroups.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Oire.SharpSync.Sync;

/// <summary>
/// Organizes sync actions into optimized processing groups
/// </summary>
internal sealed class ActionGroups {
public List<SyncAction> Directories { get; } = [];
public List<SyncAction> SmallFiles { get; } = [];
public List<SyncAction> LargeFiles { get; } = [];
public List<SyncAction> Deletes { get; } = [];
public List<SyncAction> Conflicts { get; } = [];

public void SortByPriority() {
Directories.Sort((a, b) => b.Priority.CompareTo(a.Priority));
SmallFiles.Sort((a, b) => b.Priority.CompareTo(a.Priority));
LargeFiles.Sort((a, b) => b.Priority.CompareTo(a.Priority));
Deletes.Sort((a, b) => b.Priority.CompareTo(a.Priority));
Conflicts.Sort((a, b) => b.Priority.CompareTo(a.Priority));
}

public int TotalActions => Directories.Count + SmallFiles.Count + LargeFiles.Count + Deletes.Count + Conflicts.Count;
}
12 changes: 12 additions & 0 deletions src/SharpSync/Sync/AdditionChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Oire.SharpSync.Core;

namespace Oire.SharpSync.Sync;

/// <summary>
/// Represents a new file or directory addition
/// </summary>
internal sealed class AdditionChange: IChange {
public string Path { get; set; } = string.Empty;
public SyncItem Item { get; set; } = new();
public bool IsLocal { get; set; }
}
17 changes: 17 additions & 0 deletions src/SharpSync/Sync/ChangeSet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Oire.SharpSync.Core;

namespace Oire.SharpSync.Sync;

/// <summary>
/// Represents a set of changes detected during synchronization
/// </summary>
internal sealed class ChangeSet {
public List<AdditionChange> Additions { get; } = [];
public List<ModificationChange> Modifications { get; } = [];
public List<DeletionChange> Deletions { get; } = [];
public HashSet<string> ProcessedPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public HashSet<string> LocalPaths { get; } = new(StringComparer.OrdinalIgnoreCase);
public HashSet<string> RemotePaths { get; } = new(StringComparer.OrdinalIgnoreCase);

public int TotalChanges => Additions.Count + Modifications.Count + Deletions.Count;
}
13 changes: 13 additions & 0 deletions src/SharpSync/Sync/DeletionChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Oire.SharpSync.Core;

namespace Oire.SharpSync.Sync;

/// <summary>
/// Represents a deletion of a file or directory
/// </summary>
internal sealed class DeletionChange: IChange {
public string Path { get; set; } = string.Empty;
public bool DeletedLocally { get; set; }
public bool DeletedRemotely { get; set; }
public SyncState TrackedState { get; set; } = new();
}
8 changes: 8 additions & 0 deletions src/SharpSync/Sync/IChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Oire.SharpSync.Sync;

/// <summary>
/// Represents a change detected during synchronization
/// </summary>
internal interface IChange {
string Path { get; }
}
13 changes: 13 additions & 0 deletions src/SharpSync/Sync/ModificationChange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Oire.SharpSync.Core;

namespace Oire.SharpSync.Sync;

/// <summary>
/// Represents a modification to an existing file or directory
/// </summary>
internal sealed class ModificationChange: IChange {
public string Path { get; set; } = string.Empty;
public SyncItem Item { get; set; } = new();
public bool IsLocal { get; set; }
public SyncState TrackedState { get; set; } = new();
}
15 changes: 15 additions & 0 deletions src/SharpSync/Sync/SyncAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Oire.SharpSync.Core;

namespace Oire.SharpSync.Sync;

/// <summary>
/// Represents a synchronization action to be performed
/// </summary>
internal sealed class SyncAction {
public SyncActionType Type { get; set; }
public string Path { get; set; } = string.Empty;
public SyncItem? LocalItem { get; set; }
public SyncItem? RemoteItem { get; set; }
public ConflictType ConflictType { get; set; }
public int Priority { get; set; }
}
Loading