From 9d345c42216260c0323f3491720cd171854a64e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Polykanine?= Date: Fri, 23 Jan 2026 23:28:51 +0100 Subject: [PATCH] Add recent operations and examples --- CLAUDE.md | 28 ++- examples/BasicSyncExample.cs | 238 ++++++++++++++++++ examples/README.md | 76 ++++++ src/SharpSync/Core/CompletedOperation.cs | 77 ++++++ src/SharpSync/Core/ISyncDatabase.cs | 49 ++++ src/SharpSync/Core/ISyncEngine.cs | 41 +++ src/SharpSync/Database/OperationHistory.cs | 119 +++++++++ src/SharpSync/Database/SqliteSyncDatabase.cs | 81 +++++- src/SharpSync/Logging/LogMessages.cs | 6 + src/SharpSync/Sync/SyncEngine.cs | 119 +++++++-- .../Database/SqliteSyncDatabaseTests.cs | 215 ++++++++++++++++ 11 files changed, 1027 insertions(+), 22 deletions(-) create mode 100644 examples/BasicSyncExample.cs create mode 100644 examples/README.md create mode 100644 src/SharpSync/Core/CompletedOperation.cs create mode 100644 src/SharpSync/Database/OperationHistory.cs 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); + } }