diff --git a/CLAUDE.md b/CLAUDE.md
index 073ccff..20a8814 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -307,6 +307,22 @@ await engine.SyncFolderAsync("Documents/Important");
// 9. Or sync specific files
await engine.SyncFilesAsync(new[] { "notes.txt", "config.json" });
+
+// 10. Display activity history in UI
+var recentOps = await engine.GetRecentOperationsAsync(limit: 50);
+foreach (var op in recentOps) {
+ var icon = op.ActionType switch {
+ SyncActionType.Upload => "↑",
+ SyncActionType.Download => "↓",
+ SyncActionType.DeleteLocal or SyncActionType.DeleteRemote => "×",
+ _ => "?"
+ };
+ var status = op.Success ? "✓" : "✗";
+ ActivityList.Items.Add($"{status} {icon} {op.Path} ({op.Duration.TotalSeconds:F1}s)");
+}
+
+// 11. Periodic cleanup of old history (e.g., on app startup)
+var deleted = await engine.ClearOperationHistoryAsync(DateTime.UtcNow.AddDays(-30));
```
### Current API Gaps (To Be Resolved in v1.0)
@@ -331,6 +347,8 @@ await engine.SyncFilesAsync(new[] { "notes.txt", "config.json" });
| Pending operations query | `GetPendingOperationsAsync()` - inspect sync queue for UI display |
| Clear pending changes | `ClearPendingChanges()` - discard pending notifications without syncing |
| GetSyncPlanAsync integration | `GetSyncPlanAsync()` now incorporates pending changes from notifications |
+| Activity history | `GetRecentOperationsAsync()` - query completed operations for activity feed |
+| History cleanup | `ClearOperationHistoryAsync()` - purge old operation records |
### Required SharpSync API Additions (v1.0)
@@ -341,9 +359,11 @@ These APIs are required for v1.0 release to support Nimbus desktop client:
**Progress & History:**
2. Per-file progress events (currently only per-sync-operation)
-3. `GetRecentOperationsAsync()` - Operation history for activity feed
**✅ Completed:**
+- `GetRecentOperationsAsync()` - Operation history for activity feed with time filtering
+- `ClearOperationHistoryAsync()` - Cleanup old operation history entries
+- `CompletedOperation` model - Rich operation details with timing, success/failure, rename tracking
- `SyncOptions.MaxBytesPerSecond` - Built-in bandwidth throttling
- `SyncOptions.VirtualFileCallback` - Hook for virtual file systems (Windows Cloud Files API)
- `SyncOptions.CreateVirtualFilePlaceholders` - Enable/disable virtual file placeholder creation
@@ -515,7 +535,7 @@ The core library is production-ready, but several critical items must be address
- ⚠️ All storage implementations tested (LocalFileStorage ✅, SftpStorage ✅, FtpStorage ✅, S3Storage ✅, WebDavStorage ❌)
- ❌ README matches actual API (completely wrong)
- ✅ No TODOs/FIXMEs in code (achieved)
-- ❌ Examples directory exists (missing)
+- ✅ Examples directory exists (created)
- ✅ Package metadata accurate (SFTP, FTP, and S3 now implemented!)
- ✅ Integration test infrastructure (Docker-based CI testing for SFTP, FTP, and S3)
@@ -550,12 +570,12 @@ Documentation & Testing:
- [ ] WebDavStorage integration tests
- [ ] Multi-platform CI testing (Windows, macOS)
- [ ] Code coverage reporting
-- [ ] Examples directory with working samples
+- [x] Examples directory with working samples ✅
Desktop Client APIs (for Nimbus):
- [ ] OCIS TUS protocol implementation (currently falls back to generic upload at `WebDavStorage.cs:547`)
- [ ] Per-file progress events (currently only per-sync-operation)
-- [ ] `GetRecentOperationsAsync()` - Operation history for activity feed
+- [x] `GetRecentOperationsAsync()` - Operation history for activity feed ✅
Performance & Polish:
- [ ] Performance benchmarks with BenchmarkDotNet
diff --git a/examples/BasicSyncExample.cs b/examples/BasicSyncExample.cs
new file mode 100644
index 0000000..996907b
--- /dev/null
+++ b/examples/BasicSyncExample.cs
@@ -0,0 +1,238 @@
+// =============================================================================
+// SharpSync Basic Usage Example
+// =============================================================================
+// This file demonstrates how to use SharpSync for file synchronization.
+// Copy this code into your own project that references the SharpSync NuGet package.
+//
+// Required NuGet packages:
+// - Oire.SharpSync
+// - Microsoft.Extensions.Logging.Console (optional, for logging)
+// =============================================================================
+
+using Microsoft.Extensions.Logging;
+using Oire.SharpSync.Core;
+using Oire.SharpSync.Database;
+using Oire.SharpSync.Storage;
+using Oire.SharpSync.Sync;
+
+namespace YourApp;
+
+public class SyncExample {
+ ///
+ /// Basic example: Sync local folder with a remote storage.
+ ///
+ public static async Task BasicSyncAsync() {
+ // 1. Create storage instances
+ var localStorage = new LocalFileStorage("/path/to/local/folder");
+
+ // For remote storage, choose one:
+ // - WebDavStorage for Nextcloud/ownCloud/WebDAV servers
+ // - SftpStorage for SFTP servers
+ // - FtpStorage for FTP/FTPS servers
+ // - S3Storage for AWS S3 or S3-compatible storage (MinIO, etc.)
+ var remoteStorage = new LocalFileStorage("/path/to/remote/folder"); // Demo only
+
+ // 2. Create and initialize sync database
+ var database = new SqliteSyncDatabase("/path/to/sync.db");
+ await database.InitializeAsync();
+
+ // 3. Create filter for selective sync (optional)
+ var filter = new SyncFilter();
+ filter.AddExcludePattern("*.tmp");
+ filter.AddExcludePattern("*.log");
+ filter.AddExcludePattern(".git/**");
+ filter.AddExcludePattern("node_modules/**");
+
+ // 4. Create conflict resolver
+ var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseNewer);
+
+ // 5. Create sync engine
+ using var syncEngine = new SyncEngine(
+ localStorage,
+ remoteStorage,
+ database,
+ filter,
+ conflictResolver);
+
+ // 6. Wire up events for UI updates
+ syncEngine.ProgressChanged += (sender, e) => {
+ Console.WriteLine($"[{e.Progress.Percentage:F0}%] {e.Operation}: {e.Progress.CurrentItem}");
+ };
+
+ syncEngine.ConflictDetected += (sender, e) => {
+ Console.WriteLine($"Conflict: {e.Path}");
+ };
+
+ // 7. Run synchronization
+ var result = await syncEngine.SynchronizeAsync();
+
+ Console.WriteLine($"Sync completed: {result.FilesSynchronized} files synchronized");
+ }
+
+ ///
+ /// Preview changes before syncing.
+ ///
+ public static async Task PreviewSyncAsync(ISyncEngine syncEngine) {
+ var plan = await syncEngine.GetSyncPlanAsync();
+
+ Console.WriteLine($"Uploads planned: {plan.Uploads.Count}");
+ foreach (var upload in plan.Uploads) {
+ Console.WriteLine($" + {upload.Path} ({upload.Size} bytes)");
+ }
+
+ Console.WriteLine($"Downloads planned: {plan.Downloads.Count}");
+ foreach (var download in plan.Downloads) {
+ Console.WriteLine($" - {download.Path} ({download.Size} bytes)");
+ }
+
+ Console.WriteLine($"Conflicts: {plan.Conflicts.Count}");
+ }
+
+ ///
+ /// Display activity history (recent operations).
+ ///
+ public static async Task ShowActivityHistoryAsync(ISyncEngine syncEngine) {
+ // Get last 50 operations
+ var recentOps = await syncEngine.GetRecentOperationsAsync(limit: 50);
+
+ Console.WriteLine("=== Recent Sync Activity ===");
+ foreach (var op in recentOps) {
+ var icon = op.ActionType switch {
+ SyncActionType.Upload => "↑",
+ SyncActionType.Download => "↓",
+ SyncActionType.DeleteLocal or SyncActionType.DeleteRemote => "×",
+ SyncActionType.Conflict => "!",
+ _ => "?"
+ };
+ var status = op.Success ? "✓" : "✗";
+ Console.WriteLine($"{status} {icon} {op.Path} ({op.Duration.TotalSeconds:F1}s)");
+ }
+
+ // Get operations from last hour only
+ var lastHour = await syncEngine.GetRecentOperationsAsync(
+ limit: 100,
+ since: DateTime.UtcNow.AddHours(-1));
+ Console.WriteLine($"\nOperations in last hour: {lastHour.Count}");
+
+ // Cleanup old history (e.g., on app startup)
+ var deleted = await syncEngine.ClearOperationHistoryAsync(DateTime.UtcNow.AddDays(-30));
+ Console.WriteLine($"Cleaned up {deleted} old operation records");
+ }
+
+ ///
+ /// Integrate with FileSystemWatcher for real-time sync.
+ ///
+ public static void SetupFileSystemWatcher(ISyncEngine syncEngine, string localPath) {
+ var watcher = new FileSystemWatcher(localPath) {
+ IncludeSubdirectories = true,
+ EnableRaisingEvents = true
+ };
+
+ watcher.Created += async (s, e) => {
+ var relativePath = Path.GetRelativePath(localPath, e.FullPath);
+ await syncEngine.NotifyLocalChangeAsync(relativePath, ChangeType.Created);
+ };
+
+ watcher.Changed += async (s, e) => {
+ var relativePath = Path.GetRelativePath(localPath, e.FullPath);
+ await syncEngine.NotifyLocalChangeAsync(relativePath, ChangeType.Changed);
+ };
+
+ watcher.Deleted += async (s, e) => {
+ var relativePath = Path.GetRelativePath(localPath, e.FullPath);
+ await syncEngine.NotifyLocalChangeAsync(relativePath, ChangeType.Deleted);
+ };
+
+ watcher.Renamed += async (s, e) => {
+ var oldRelativePath = Path.GetRelativePath(localPath, e.OldFullPath);
+ var newRelativePath = Path.GetRelativePath(localPath, e.FullPath);
+ await syncEngine.NotifyLocalRenameAsync(oldRelativePath, newRelativePath);
+ };
+
+ // Check pending operations
+ Task.Run(async () => {
+ var pending = await syncEngine.GetPendingOperationsAsync();
+ Console.WriteLine($"Pending operations: {pending.Count}");
+ });
+ }
+
+ ///
+ /// Sync specific files on demand.
+ ///
+ public static async Task SyncSpecificFilesAsync(ISyncEngine syncEngine) {
+ // Sync a specific folder
+ var folderResult = await syncEngine.SyncFolderAsync("Documents/Important");
+ Console.WriteLine($"Folder sync: {folderResult.FilesSynchronized} files");
+
+ // Sync specific files
+ var fileResult = await syncEngine.SyncFilesAsync(new[] {
+ "config.json",
+ "data/settings.xml"
+ });
+ Console.WriteLine($"File sync: {fileResult.FilesSynchronized} files");
+ }
+
+ ///
+ /// Pause and resume sync operations.
+ ///
+ public static async Task PauseResumeDemoAsync(ISyncEngine syncEngine, CancellationToken ct) {
+ // Start sync in background
+ var syncTask = syncEngine.SynchronizeAsync(cancellationToken: ct);
+
+ // Pause after some time
+ await Task.Delay(1000);
+ if (syncEngine.State == SyncEngineState.Running) {
+ await syncEngine.PauseAsync();
+ Console.WriteLine($"Sync paused. State: {syncEngine.State}");
+
+ // Do something while paused...
+ await Task.Delay(2000);
+
+ // Resume
+ await syncEngine.ResumeAsync();
+ Console.WriteLine($"Sync resumed. State: {syncEngine.State}");
+ }
+
+ await syncTask;
+ }
+
+ ///
+ /// Configure bandwidth throttling.
+ ///
+ public static async Task ThrottledSyncAsync(ISyncEngine syncEngine) {
+ var options = new SyncOptions {
+ // Limit to 1 MB/s
+ MaxBytesPerSecond = 1024 * 1024
+ };
+
+ var result = await syncEngine.SynchronizeAsync(options);
+ Console.WriteLine($"Throttled sync completed: {result.FilesSynchronized} files");
+ }
+
+ ///
+ /// Smart conflict resolution with UI callback.
+ ///
+ public static ISyncEngine CreateEngineWithSmartConflictResolver(
+ ISyncStorage localStorage,
+ ISyncStorage remoteStorage,
+ ISyncDatabase database,
+ ISyncFilter filter) {
+ // SmartConflictResolver analyzes conflicts and can prompt the user
+ var resolver = new SmartConflictResolver(
+ conflictHandler: async (analysis, ct) => {
+ // This callback is invoked for each conflict
+ Console.WriteLine($"Conflict: {analysis.Path}");
+ Console.WriteLine($" Local: {analysis.LocalSize} bytes, modified {analysis.LocalModified}");
+ Console.WriteLine($" Remote: {analysis.RemoteSize} bytes, modified {analysis.RemoteModified}");
+ Console.WriteLine($" Recommendation: {analysis.Recommendation}");
+ Console.WriteLine($" Reason: {analysis.ReasonForRecommendation}");
+
+ // In a real app, show a dialog and return user's choice
+ // For this example, accept the recommendation
+ return analysis.Recommendation;
+ },
+ defaultResolution: ConflictResolution.Ask);
+
+ return new SyncEngine(localStorage, remoteStorage, database, filter, resolver);
+ }
+}
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..8bc1d04
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,76 @@
+# SharpSync Examples
+
+This directory contains example code demonstrating how to use the SharpSync library.
+
+## BasicSyncExample.cs
+
+A comprehensive example showing:
+
+- **Basic sync setup** - Creating storage, database, filter, and sync engine
+- **Progress events** - Wiring up UI updates during sync
+- **Sync preview** - Previewing changes before executing
+- **Activity history** - Using `GetRecentOperationsAsync()` to display sync history
+- **FileSystemWatcher integration** - Real-time change detection
+- **Selective sync** - Syncing specific files or folders on demand
+- **Pause/Resume** - Controlling long-running sync operations
+- **Bandwidth throttling** - Limiting transfer speeds
+- **Smart conflict resolution** - Handling conflicts with UI prompts
+
+## Usage
+
+This is a standalone example file, not a buildable project. To use it:
+
+1. Create a new .NET 8.0+ project
+2. Add the SharpSync NuGet package:
+ ```bash
+ dotnet add package Oire.SharpSync
+ ```
+3. Optionally add logging:
+ ```bash
+ dotnet add package Microsoft.Extensions.Logging.Console
+ ```
+4. Copy the relevant code from `BasicSyncExample.cs` into your project
+
+## Storage Options
+
+SharpSync supports multiple storage backends:
+
+| Storage | Class | Use Case |
+|---------|-------|----------|
+| Local filesystem | `LocalFileStorage` | Local folders, testing |
+| WebDAV | `WebDavStorage` | Nextcloud, ownCloud, any WebDAV server |
+| SFTP | `SftpStorage` | SSH/SFTP servers |
+| FTP/FTPS | `FtpStorage` | FTP servers with optional TLS |
+| S3 | `S3Storage` | AWS S3, MinIO, LocalStack, S3-compatible |
+
+## Quick Start
+
+```csharp
+using Oire.SharpSync.Core;
+using Oire.SharpSync.Database;
+using Oire.SharpSync.Storage;
+using Oire.SharpSync.Sync;
+
+// Create storage instances
+var localStorage = new LocalFileStorage("/local/path");
+var remoteStorage = new SftpStorage("sftp.example.com", "user", "password", "/remote/path");
+
+// Create database
+var database = new SqliteSyncDatabase("/path/to/sync.db");
+await database.InitializeAsync();
+
+// Create sync engine
+var filter = new SyncFilter();
+var resolver = new DefaultConflictResolver(ConflictResolution.UseNewer);
+using var engine = new SyncEngine(localStorage, remoteStorage, database, filter, resolver);
+
+// Run sync
+var result = await engine.SynchronizeAsync();
+Console.WriteLine($"Synced {result.FilesSynchronized} files");
+
+// View activity history
+var history = await engine.GetRecentOperationsAsync(limit: 20);
+foreach (var op in history) {
+ Console.WriteLine($"{op.ActionType}: {op.Path} ({op.Duration.TotalSeconds:F1}s)");
+}
+```
diff --git a/src/SharpSync/Core/CompletedOperation.cs b/src/SharpSync/Core/CompletedOperation.cs
new file mode 100644
index 0000000..327fa82
--- /dev/null
+++ b/src/SharpSync/Core/CompletedOperation.cs
@@ -0,0 +1,77 @@
+namespace Oire.SharpSync.Core;
+
+///
+/// Represents a completed synchronization operation for activity history.
+/// Desktop clients can use this to display recent sync activity in their UI.
+///
+public record CompletedOperation {
+ ///
+ /// Unique identifier for this operation
+ ///
+ public long Id { get; init; }
+
+ ///
+ /// The relative path of the file or directory that was synchronized
+ ///
+ public required string Path { get; init; }
+
+ ///
+ /// The type of operation that was performed
+ ///
+ public required SyncActionType ActionType { get; init; }
+
+ ///
+ /// Whether the item is a directory
+ ///
+ public bool IsDirectory { get; init; }
+
+ ///
+ /// The size of the file in bytes (0 for directories)
+ ///
+ public long Size { get; init; }
+
+ ///
+ /// The source of the change that triggered this operation
+ ///
+ public ChangeSource Source { get; init; }
+
+ ///
+ /// When the operation started
+ ///
+ public DateTime StartedAt { get; init; }
+
+ ///
+ /// When the operation completed
+ ///
+ public DateTime CompletedAt { get; init; }
+
+ ///
+ /// Whether the operation completed successfully
+ ///
+ public bool Success { get; init; }
+
+ ///
+ /// Error message if the operation failed (null on success)
+ ///
+ public string? ErrorMessage { get; init; }
+
+ ///
+ /// Duration of the operation
+ ///
+ public TimeSpan Duration => CompletedAt - StartedAt;
+
+ ///
+ /// For rename operations, the original path before the rename
+ ///
+ public string? RenamedFrom { get; init; }
+
+ ///
+ /// For rename operations, the new path after the rename
+ ///
+ public string? RenamedTo { get; init; }
+
+ ///
+ /// Indicates whether this operation was part of a rename
+ ///
+ public bool IsRename => RenamedFrom is not null || RenamedTo is not null;
+}
diff --git a/src/SharpSync/Core/ISyncDatabase.cs b/src/SharpSync/Core/ISyncDatabase.cs
index 2f7ab8e..3d796ef 100644
--- a/src/SharpSync/Core/ISyncDatabase.cs
+++ b/src/SharpSync/Core/ISyncDatabase.cs
@@ -57,4 +57,53 @@ public interface ISyncDatabase: IDisposable {
/// Gets database statistics
///
Task GetStatsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Logs a completed synchronization operation for activity history.
+ ///
+ /// The relative path of the file or directory
+ /// The type of operation that was performed
+ /// Whether the item is a directory
+ /// The size of the file in bytes (0 for directories)
+ /// The source of the change that triggered this operation
+ /// When the operation started
+ /// When the operation completed
+ /// Whether the operation completed successfully
+ /// Error message if the operation failed
+ /// Original path for rename operations
+ /// New path for rename operations
+ /// Cancellation token to cancel the operation
+ Task LogOperationAsync(
+ string path,
+ SyncActionType actionType,
+ bool isDirectory,
+ long size,
+ ChangeSource source,
+ DateTime startedAt,
+ DateTime completedAt,
+ bool success,
+ string? errorMessage = null,
+ string? renamedFrom = null,
+ string? renamedTo = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets recent completed operations for activity history display.
+ ///
+ /// Maximum number of operations to return (default: 100)
+ /// Only return operations completed after this time (optional)
+ /// Cancellation token to cancel the operation
+ /// A collection of completed operations ordered by completion time descending
+ Task> GetRecentOperationsAsync(
+ int limit = 100,
+ DateTime? since = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Clears operation history older than the specified date.
+ ///
+ /// Delete operations completed before this date
+ /// Cancellation token to cancel the operation
+ /// The number of operations deleted
+ Task ClearOperationHistoryAsync(DateTime olderThan, CancellationToken cancellationToken = default);
}
diff --git a/src/SharpSync/Core/ISyncEngine.cs b/src/SharpSync/Core/ISyncEngine.cs
index 2765d13..173d035 100644
--- a/src/SharpSync/Core/ISyncEngine.cs
+++ b/src/SharpSync/Core/ISyncEngine.cs
@@ -281,4 +281,45 @@ public interface ISyncEngine: IDisposable {
///
/// Thrown when the sync engine has been disposed
void ClearPendingChanges();
+
+ ///
+ /// Gets recent completed operations for activity history display.
+ ///
+ /// Maximum number of operations to return (default: 100)
+ /// Only return operations completed after this time (optional)
+ /// Cancellation token to cancel the operation
+ /// A collection of completed operations ordered by completion time descending
+ ///
+ ///
+ /// Desktop clients can use this method to:
+ ///
+ /// - Display an activity feed showing recent sync operations
+ /// - Show users what files were recently uploaded, downloaded, or deleted
+ /// - Build a sync history view with filtering by time
+ /// - Detect failed operations that may need attention
+ ///
+ ///
+ ///
+ /// Operations are logged automatically during synchronization. Both successful and failed
+ /// operations are recorded to provide a complete activity history.
+ ///
+ ///
+ /// Thrown when the sync engine has been disposed
+ Task> GetRecentOperationsAsync(
+ int limit = 100,
+ DateTime? since = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Clears operation history older than the specified date.
+ ///
+ /// Delete operations completed before this date
+ /// Cancellation token to cancel the operation
+ /// The number of operations deleted
+ ///
+ /// Use this method periodically to prevent the operation history from growing indefinitely.
+ /// For example, you might clear operations older than 30 days.
+ ///
+ /// Thrown when the sync engine has been disposed
+ Task ClearOperationHistoryAsync(DateTime olderThan, CancellationToken cancellationToken = default);
}
diff --git a/src/SharpSync/Database/OperationHistory.cs b/src/SharpSync/Database/OperationHistory.cs
new file mode 100644
index 0000000..2b2ed5a
--- /dev/null
+++ b/src/SharpSync/Database/OperationHistory.cs
@@ -0,0 +1,119 @@
+using SQLite;
+using Oire.SharpSync.Core;
+
+namespace Oire.SharpSync.Database;
+
+///
+/// SQLite table model for persisting completed sync operations.
+///
+[Table("OperationHistory")]
+internal sealed class OperationHistory {
+ ///
+ /// Unique identifier for this operation record
+ ///
+ [PrimaryKey, AutoIncrement]
+ public long Id { get; set; }
+
+ ///
+ /// The relative path of the file or directory
+ ///
+ [Indexed]
+ public string Path { get; set; } = string.Empty;
+
+ ///
+ /// The type of operation (stored as integer)
+ ///
+ public int ActionType { get; set; }
+
+ ///
+ /// Whether the item is a directory
+ ///
+ public bool IsDirectory { get; set; }
+
+ ///
+ /// File size in bytes
+ ///
+ public long Size { get; set; }
+
+ ///
+ /// Source of the change (Local = 0, Remote = 1)
+ ///
+ public int Source { get; set; }
+
+ ///
+ /// When the operation started (stored as ticks)
+ ///
+ public long StartedAtTicks { get; set; }
+
+ ///
+ /// When the operation completed (stored as ticks)
+ ///
+ [Indexed]
+ public long CompletedAtTicks { get; set; }
+
+ ///
+ /// Whether the operation succeeded
+ ///
+ public bool Success { get; set; }
+
+ ///
+ /// Error message if failed
+ ///
+ public string? ErrorMessage { get; set; }
+
+ ///
+ /// Original path for rename operations
+ ///
+ public string? RenamedFrom { get; set; }
+
+ ///
+ /// New path for rename operations
+ ///
+ public string? RenamedTo { get; set; }
+
+ ///
+ /// Converts this database record to a CompletedOperation domain model
+ ///
+ public CompletedOperation ToCompletedOperation() => new() {
+ Id = Id,
+ Path = Path,
+ ActionType = (SyncActionType)ActionType,
+ IsDirectory = IsDirectory,
+ Size = Size,
+ Source = (ChangeSource)Source,
+ StartedAt = new DateTime(StartedAtTicks, DateTimeKind.Utc),
+ CompletedAt = new DateTime(CompletedAtTicks, DateTimeKind.Utc),
+ Success = Success,
+ ErrorMessage = ErrorMessage,
+ RenamedFrom = RenamedFrom,
+ RenamedTo = RenamedTo
+ };
+
+ ///
+ /// Creates a database record from operation details
+ ///
+ public static OperationHistory FromOperation(
+ string path,
+ SyncActionType actionType,
+ bool isDirectory,
+ long size,
+ ChangeSource source,
+ DateTime startedAt,
+ DateTime completedAt,
+ bool success,
+ string? errorMessage = null,
+ string? renamedFrom = null,
+ string? renamedTo = null) => new() {
+ Path = path,
+ ActionType = (int)actionType,
+ IsDirectory = isDirectory,
+ Size = size,
+ Source = (int)source,
+ StartedAtTicks = startedAt.Ticks,
+ CompletedAtTicks = completedAt.Ticks,
+ Success = success,
+ ErrorMessage = errorMessage,
+ RenamedFrom = renamedFrom,
+ RenamedTo = renamedTo
+ };
+}
diff --git a/src/SharpSync/Database/SqliteSyncDatabase.cs b/src/SharpSync/Database/SqliteSyncDatabase.cs
index 2fb42e8..2a481e6 100644
--- a/src/SharpSync/Database/SqliteSyncDatabase.cs
+++ b/src/SharpSync/Database/SqliteSyncDatabase.cs
@@ -42,14 +42,19 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default)
_connection = new SQLiteAsyncConnection(_databasePath);
await _connection.CreateTableAsync();
+ await _connection.CreateTableAsync();
await _connection.ExecuteAsync("""
- CREATE INDEX IF NOT EXISTS idx_syncstates_status
+ CREATE INDEX IF NOT EXISTS idx_syncstates_status
ON SyncStates(Status)
""");
await _connection.ExecuteAsync("""
- CREATE INDEX IF NOT EXISTS idx_syncstates_lastsync
+ CREATE INDEX IF NOT EXISTS idx_syncstates_lastsync
ON SyncStates(LastSyncTime)
""");
+ await _connection.ExecuteAsync("""
+ CREATE INDEX IF NOT EXISTS idx_operationhistory_completedat
+ ON OperationHistory(CompletedAtTicks DESC)
+ """);
}
///
@@ -209,6 +214,78 @@ public async Task GetStatsAsync(CancellationToken cancellationTok
};
}
+ ///
+ /// Logs a completed synchronization operation for activity history.
+ ///
+ public async Task LogOperationAsync(
+ string path,
+ SyncActionType actionType,
+ bool isDirectory,
+ long size,
+ ChangeSource source,
+ DateTime startedAt,
+ DateTime completedAt,
+ bool success,
+ string? errorMessage = null,
+ string? renamedFrom = null,
+ string? renamedTo = null,
+ CancellationToken cancellationToken = default) {
+ EnsureInitialized();
+
+ var record = OperationHistory.FromOperation(
+ path,
+ actionType,
+ isDirectory,
+ size,
+ source,
+ startedAt,
+ completedAt,
+ success,
+ errorMessage,
+ renamedFrom,
+ renamedTo);
+
+ await _connection!.InsertAsync(record);
+ }
+
+ ///
+ /// Gets recent completed operations for activity history display.
+ ///
+ public async Task> GetRecentOperationsAsync(
+ int limit = 100,
+ DateTime? since = null,
+ CancellationToken cancellationToken = default) {
+ EnsureInitialized();
+
+ List records;
+
+ if (since.HasValue) {
+ var sinceTicks = since.Value.Ticks;
+ records = await _connection!.QueryAsync(
+ "SELECT * FROM OperationHistory WHERE CompletedAtTicks > ? ORDER BY CompletedAtTicks DESC LIMIT ?",
+ sinceTicks,
+ limit);
+ } else {
+ records = await _connection!.QueryAsync(
+ "SELECT * FROM OperationHistory ORDER BY CompletedAtTicks DESC LIMIT ?",
+ limit);
+ }
+
+ return records.Select(r => r.ToCompletedOperation()).ToList();
+ }
+
+ ///
+ /// Clears operation history older than the specified date.
+ ///
+ public async Task ClearOperationHistoryAsync(DateTime olderThan, CancellationToken cancellationToken = default) {
+ EnsureInitialized();
+
+ var olderThanTicks = olderThan.Ticks;
+ return await _connection!.ExecuteAsync(
+ "DELETE FROM OperationHistory WHERE CompletedAtTicks < ?",
+ olderThanTicks);
+ }
+
private void EnsureInitialized() {
if (_connection is null) {
throw new InvalidOperationException("Database not initialized. Call InitializeAsync first.");
diff --git a/src/SharpSync/Logging/LogMessages.cs b/src/SharpSync/Logging/LogMessages.cs
index a650093..b273f7d 100644
--- a/src/SharpSync/Logging/LogMessages.cs
+++ b/src/SharpSync/Logging/LogMessages.cs
@@ -71,4 +71,10 @@ internal static partial class LogMessages {
Level = LogLevel.Debug,
Message = "Local change notified: {Path} ({ChangeType})")]
public static partial void LocalChangeNotified(this ILogger logger, string path, Core.ChangeType changeType);
+
+ [LoggerMessage(
+ EventId = 12,
+ Level = LogLevel.Warning,
+ Message = "Failed to log operation for {Path}")]
+ public static partial void OperationLoggingError(this ILogger logger, Exception ex, string path);
}
diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs
index 3188b68..5dead6c 100644
--- a/src/SharpSync/Sync/SyncEngine.cs
+++ b/src/SharpSync/Sync/SyncEngine.cs
@@ -946,26 +946,88 @@ private async Task ProcessPhase3_DeletesAndConflictsAsync(
}
private async Task ProcessActionAsync(SyncAction action, ThreadSafeSyncResult result, CancellationToken cancellationToken) {
- switch (action.Type) {
- case SyncActionType.Download:
- await DownloadFileAsync(action, result, cancellationToken);
- break;
+ var startedAt = DateTime.UtcNow;
+ var success = true;
+ string? errorMessage = null;
- case SyncActionType.Upload:
- await UploadFileAsync(action, result, cancellationToken);
- break;
+ try {
+ switch (action.Type) {
+ case SyncActionType.Download:
+ await DownloadFileAsync(action, result, cancellationToken);
+ break;
- case SyncActionType.DeleteLocal:
- await DeleteLocalAsync(action, result, cancellationToken);
- break;
+ case SyncActionType.Upload:
+ await UploadFileAsync(action, result, cancellationToken);
+ break;
- case SyncActionType.DeleteRemote:
- await DeleteRemoteAsync(action, result, cancellationToken);
- break;
+ case SyncActionType.DeleteLocal:
+ await DeleteLocalAsync(action, result, cancellationToken);
+ break;
- case SyncActionType.Conflict:
- await ResolveConflictAsync(action, result, cancellationToken);
- break;
+ case SyncActionType.DeleteRemote:
+ await DeleteRemoteAsync(action, result, cancellationToken);
+ break;
+
+ case SyncActionType.Conflict:
+ await ResolveConflictAsync(action, result, cancellationToken);
+ break;
+ }
+ } catch (OperationCanceledException) {
+ // Don't log cancelled operations
+ throw;
+ } catch (Exception ex) {
+ success = false;
+ errorMessage = ex.Message;
+ throw;
+ } finally {
+ // Log the operation unless it was cancelled
+ if (!cancellationToken.IsCancellationRequested) {
+ await LogOperationAsync(action, startedAt, success, errorMessage);
+ }
+ }
+ }
+
+ ///
+ /// Logs a completed operation to the database for activity history.
+ ///
+ private async Task LogOperationAsync(SyncAction action, DateTime startedAt, bool success, string? errorMessage) {
+ try {
+ var isDirectory = action.LocalItem?.IsDirectory ?? action.RemoteItem?.IsDirectory ?? false;
+ var size = action.LocalItem?.Size ?? action.RemoteItem?.Size ?? 0;
+
+ // Determine the change source based on action type
+ var source = action.Type switch {
+ SyncActionType.Download => ChangeSource.Remote,
+ SyncActionType.DeleteLocal => ChangeSource.Remote,
+ SyncActionType.Upload => ChangeSource.Local,
+ SyncActionType.DeleteRemote => ChangeSource.Local,
+ SyncActionType.Conflict => ChangeSource.Local, // Default to local for conflicts
+ _ => ChangeSource.Local
+ };
+
+ // Check for rename information from pending changes
+ string? renamedFrom = null;
+ string? renamedTo = null;
+ if (_pendingChanges.TryGetValue(action.Path, out var pendingChange)) {
+ renamedFrom = pendingChange.RenamedFrom;
+ renamedTo = pendingChange.RenamedTo;
+ }
+
+ await _database.LogOperationAsync(
+ action.Path,
+ action.Type,
+ isDirectory,
+ size,
+ source,
+ startedAt,
+ DateTime.UtcNow,
+ success,
+ errorMessage,
+ renamedFrom,
+ renamedTo);
+ } catch (Exception ex) {
+ // Don't fail the sync operation if logging fails
+ _logger.OperationLoggingError(ex, action.Path);
}
}
@@ -2039,6 +2101,31 @@ public void ClearPendingChanges() {
_pendingChanges.Clear();
}
+ ///
+ /// Gets recent completed operations for activity history display.
+ ///
+ public async Task> GetRecentOperationsAsync(
+ int limit = 100,
+ DateTime? since = null,
+ CancellationToken cancellationToken = default) {
+ if (_disposed) {
+ throw new ObjectDisposedException(nameof(SyncEngine));
+ }
+
+ return await _database.GetRecentOperationsAsync(limit, since, cancellationToken);
+ }
+
+ ///
+ /// Clears operation history older than the specified date.
+ ///
+ public async Task ClearOperationHistoryAsync(DateTime olderThan, CancellationToken cancellationToken = default) {
+ if (_disposed) {
+ throw new ObjectDisposedException(nameof(SyncEngine));
+ }
+
+ return await _database.ClearOperationHistoryAsync(olderThan, cancellationToken);
+ }
+
///
/// Releases all resources used by the sync engine
///
diff --git a/tests/SharpSync.Tests/Database/SqliteSyncDatabaseTests.cs b/tests/SharpSync.Tests/Database/SqliteSyncDatabaseTests.cs
index 189786d..97f3738 100644
--- a/tests/SharpSync.Tests/Database/SqliteSyncDatabaseTests.cs
+++ b/tests/SharpSync.Tests/Database/SqliteSyncDatabaseTests.cs
@@ -322,4 +322,219 @@ public void Dispose_MultipleCalls_DoesNotThrow() {
database.Dispose();
database.Dispose(); // Should not throw
}
+
+ [Fact]
+ public async Task LogOperationAsync_LogsOperation() {
+ // Arrange
+ var startedAt = DateTime.UtcNow.AddSeconds(-1);
+ var completedAt = DateTime.UtcNow;
+
+ // Act
+ await _database.LogOperationAsync(
+ "test.txt",
+ SyncActionType.Upload,
+ isDirectory: false,
+ size: 1024,
+ ChangeSource.Local,
+ startedAt,
+ completedAt,
+ success: true);
+
+ // Assert
+ var operations = await _database.GetRecentOperationsAsync();
+ Assert.Single(operations);
+ Assert.Equal("test.txt", operations[0].Path);
+ Assert.Equal(SyncActionType.Upload, operations[0].ActionType);
+ Assert.False(operations[0].IsDirectory);
+ Assert.Equal(1024, operations[0].Size);
+ Assert.Equal(ChangeSource.Local, operations[0].Source);
+ Assert.True(operations[0].Success);
+ Assert.Null(operations[0].ErrorMessage);
+ }
+
+ [Fact]
+ public async Task LogOperationAsync_FailedOperation_IncludesErrorMessage() {
+ // Arrange
+ var startedAt = DateTime.UtcNow.AddSeconds(-1);
+ var completedAt = DateTime.UtcNow;
+
+ // Act
+ await _database.LogOperationAsync(
+ "failed.txt",
+ SyncActionType.Download,
+ isDirectory: false,
+ size: 2048,
+ ChangeSource.Remote,
+ startedAt,
+ completedAt,
+ success: false,
+ errorMessage: "Network error");
+
+ // Assert
+ var operations = await _database.GetRecentOperationsAsync();
+ Assert.Single(operations);
+ Assert.Equal("failed.txt", operations[0].Path);
+ Assert.False(operations[0].Success);
+ Assert.Equal("Network error", operations[0].ErrorMessage);
+ }
+
+ [Fact]
+ public async Task LogOperationAsync_RenameOperation_IncludesRenameInfo() {
+ // Arrange
+ var startedAt = DateTime.UtcNow.AddSeconds(-1);
+ var completedAt = DateTime.UtcNow;
+
+ // Act
+ await _database.LogOperationAsync(
+ "newname.txt",
+ SyncActionType.Upload,
+ isDirectory: false,
+ size: 512,
+ ChangeSource.Local,
+ startedAt,
+ completedAt,
+ success: true,
+ renamedFrom: "oldname.txt",
+ renamedTo: "newname.txt");
+
+ // Assert
+ var operations = await _database.GetRecentOperationsAsync();
+ Assert.Single(operations);
+ Assert.True(operations[0].IsRename);
+ Assert.Equal("oldname.txt", operations[0].RenamedFrom);
+ Assert.Equal("newname.txt", operations[0].RenamedTo);
+ }
+
+ [Fact]
+ public async Task GetRecentOperationsAsync_EmptyDatabase_ReturnsEmpty() {
+ // Act
+ var operations = await _database.GetRecentOperationsAsync();
+
+ // Assert
+ Assert.Empty(operations);
+ }
+
+ [Fact]
+ public async Task GetRecentOperationsAsync_ReturnsOrderedByCompletionTimeDescending() {
+ // Arrange
+ var now = DateTime.UtcNow;
+ await _database.LogOperationAsync("first.txt", SyncActionType.Upload, false, 100, ChangeSource.Local, now.AddMinutes(-3), now.AddMinutes(-2), true);
+ await _database.LogOperationAsync("second.txt", SyncActionType.Upload, false, 100, ChangeSource.Local, now.AddMinutes(-2), now.AddMinutes(-1), true);
+ await _database.LogOperationAsync("third.txt", SyncActionType.Upload, false, 100, ChangeSource.Local, now.AddMinutes(-1), now, true);
+
+ // Act
+ var operations = await _database.GetRecentOperationsAsync();
+
+ // Assert
+ Assert.Equal(3, operations.Count);
+ Assert.Equal("third.txt", operations[0].Path); // Most recent first
+ Assert.Equal("second.txt", operations[1].Path);
+ Assert.Equal("first.txt", operations[2].Path); // Oldest last
+ }
+
+ [Fact]
+ public async Task GetRecentOperationsAsync_RespectsLimit() {
+ // Arrange
+ var now = DateTime.UtcNow;
+ for (int i = 0; i < 10; i++) {
+ await _database.LogOperationAsync($"file{i}.txt", SyncActionType.Upload, false, 100, ChangeSource.Local, now.AddMinutes(-i - 1), now.AddMinutes(-i), true);
+ }
+
+ // Act
+ var operations = await _database.GetRecentOperationsAsync(limit: 5);
+
+ // Assert
+ Assert.Equal(5, operations.Count);
+ }
+
+ [Fact]
+ public async Task GetRecentOperationsAsync_FiltersBySinceDate() {
+ // Arrange
+ var now = DateTime.UtcNow;
+ await _database.LogOperationAsync("old.txt", SyncActionType.Upload, false, 100, ChangeSource.Local, now.AddHours(-3), now.AddHours(-2), true);
+ await _database.LogOperationAsync("recent.txt", SyncActionType.Upload, false, 100, ChangeSource.Local, now.AddMinutes(-30), now.AddMinutes(-20), true);
+
+ // Act
+ var operations = await _database.GetRecentOperationsAsync(since: now.AddHours(-1));
+
+ // Assert
+ Assert.Single(operations);
+ Assert.Equal("recent.txt", operations[0].Path);
+ }
+
+ [Fact]
+ public async Task ClearOperationHistoryAsync_RemovesOldOperations() {
+ // Arrange
+ var now = DateTime.UtcNow;
+ await _database.LogOperationAsync("old.txt", SyncActionType.Upload, false, 100, ChangeSource.Local, now.AddDays(-10), now.AddDays(-9), true);
+ await _database.LogOperationAsync("recent.txt", SyncActionType.Upload, false, 100, ChangeSource.Local, now.AddMinutes(-30), now.AddMinutes(-20), true);
+
+ // Act
+ var deleted = await _database.ClearOperationHistoryAsync(now.AddDays(-1));
+
+ // Assert
+ Assert.Equal(1, deleted);
+ var operations = await _database.GetRecentOperationsAsync();
+ Assert.Single(operations);
+ Assert.Equal("recent.txt", operations[0].Path);
+ }
+
+ [Fact]
+ public async Task GetRecentOperationsAsync_CalculatesDurationCorrectly() {
+ // Arrange
+ var startedAt = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
+ var completedAt = new DateTime(2024, 1, 1, 12, 0, 30, DateTimeKind.Utc); // 30 seconds later
+
+ await _database.LogOperationAsync("timed.txt", SyncActionType.Download, false, 1024, ChangeSource.Remote, startedAt, completedAt, true);
+
+ // Act
+ var operations = await _database.GetRecentOperationsAsync();
+
+ // Assert
+ Assert.Single(operations);
+ Assert.Equal(TimeSpan.FromSeconds(30), operations[0].Duration);
+ }
+
+ [Fact]
+ public async Task LogOperationAsync_DirectoryOperation_SetsIsDirectoryTrue() {
+ // Act
+ await _database.LogOperationAsync(
+ "testdir",
+ SyncActionType.Upload,
+ isDirectory: true,
+ size: 0,
+ ChangeSource.Local,
+ DateTime.UtcNow.AddSeconds(-1),
+ DateTime.UtcNow,
+ success: true);
+
+ // Assert
+ var operations = await _database.GetRecentOperationsAsync();
+ Assert.Single(operations);
+ Assert.True(operations[0].IsDirectory);
+ }
+
+ [Theory]
+ [InlineData(SyncActionType.Upload)]
+ [InlineData(SyncActionType.Download)]
+ [InlineData(SyncActionType.DeleteLocal)]
+ [InlineData(SyncActionType.DeleteRemote)]
+ [InlineData(SyncActionType.Conflict)]
+ public async Task LogOperationAsync_AllActionTypes_PersistCorrectly(SyncActionType actionType) {
+ // Act
+ await _database.LogOperationAsync(
+ $"test_{actionType}.txt",
+ actionType,
+ isDirectory: false,
+ size: 100,
+ ChangeSource.Local,
+ DateTime.UtcNow.AddSeconds(-1),
+ DateTime.UtcNow,
+ success: true);
+
+ // Assert
+ var operations = await _database.GetRecentOperationsAsync();
+ Assert.Single(operations);
+ Assert.Equal(actionType, operations[0].ActionType);
+ }
}