diff --git a/.claude/agent-memory/dotnet-style-corrector/MEMORY.md b/.claude/agent-memory/dotnet-style-corrector/MEMORY.md index ddaed01..ec00a9b 100644 --- a/.claude/agent-memory/dotnet-style-corrector/MEMORY.md +++ b/.claude/agent-memory/dotnet-style-corrector/MEMORY.md @@ -28,3 +28,36 @@ Test code in this project follows consistent patterns: - Descriptive test names with `MethodName_Scenario_ExpectedResult` format - Good use of collection expressions (`Array.Empty()`, `new[] { ... }`) - Thread safety tests use proper error capturing with lock statements + +### Sample Code Review (2026-02-13) +Reviewed all files in `samples/SharpSync.Samples.Console/`: +- Fixed brace placement for all class declarations, method declarations, control flow statements, and exception handlers +- Fixed object initializer bracing (dictionaries, object initializers with `new`) +- Files affected: `Program.cs`, `BasicSyncExample.cs`, `ConsoleOAuth2Example.cs` +- All files now comply with `.editorconfig` brace placement rules +- Build verification: ✅ No compilation errors + +## Style Enforcement Learnings + +### Opening Brace Placement +Per `.editorconfig`: `csharp_new_line_before_open_brace = none` means opening braces go on the SAME line as the preceding code element (class name, method name, if/for/while, catch/finally, etc.). This project uses Allman/BSD style consistently. + +**Correct pattern:** +```csharp +public class MyClass +{ + public void MyMethod() + { + if (condition) + { + // code + } + } +} +``` + +### Common Patterns Requiring Fixes in Sample Code +1. **Lambda expressions with blocks**: Opening brace on new line after `=>` +2. **Dictionary initializers**: Opening brace on new line after `new Dictionary` +3. **Exception handlers**: `catch` and `finally` clauses need opening braces on new line +4. **Else clauses**: Despite setting `csharp_new_line_before_else = false`, opening brace for else block goes on new line diff --git a/CLAUDE.md b/CLAUDE.md index a25d156..62afb03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,9 +218,12 @@ See `src/SharpSync/SharpSync.csproj` for current versions. │ ├── Database/ │ ├── Storage/ │ └── Sync/ -├── examples/ # Usage examples -│ ├── BasicSyncExample.cs -│ ├── ConsoleOAuth2Example.cs +├── samples/ # Runnable sample applications +│ ├── SharpSync.Samples.Console/ # Interactive console demo +│ │ ├── SharpSync.Samples.Console.csproj +│ │ ├── Program.cs +│ │ ├── BasicSyncExample.cs +│ │ └── ConsoleOAuth2Example.cs │ └── README.md └── .github/ └── workflows/ # CI/CD configuration @@ -488,7 +491,7 @@ The core library is production-ready. All critical items are complete and the li - `.editorconfig` with comprehensive C# style rules - Multi-platform CI/CD pipeline (Ubuntu, Windows, macOS with matrix strategy) - Integration tests for all storage backends (SFTP, FTP, S3, WebDAV) via Docker on Ubuntu -- Examples directory with working samples +- Samples directory with buildable sample project ### 🚨 CRITICAL (Must Fix Before v1.0) @@ -501,7 +504,7 @@ All critical items have been resolved. - ✅ All storage implementations tested (LocalFileStorage, SftpStorage, FtpStorage, S3Storage, WebDavStorage) - ✅ README matches actual API - ✅ No TODOs/FIXMEs in code -- ✅ Examples directory exists +- ✅ Samples directory with buildable project - ✅ Package metadata accurate - ✅ Integration test infrastructure (Docker-based CI for all backends) - ✅ Multi-platform CI (Ubuntu, Windows, macOS) @@ -528,7 +531,7 @@ All critical items have been resolved. - ✅ Per-file progress events (`FileProgressChanged` on `ISyncEngine`, `FileProgressEventArgs`, `FileTransferOperation`) - ✅ Examples directory with working samples - ✅ Code coverage reporting (Coverlet + Codecov with badge in README) -- ✅ Console OAuth2 provider example (`examples/ConsoleOAuth2Example.cs`) +- ✅ Console OAuth2 provider example (`samples/SharpSync.Samples.Console/ConsoleOAuth2Example.cs`) - ✅ All `SyncOptions` properties wired and functional (TimeoutSeconds, ChecksumOnly, SizeOnly, UpdateExisting, ConflictResolution override, ExcludePatterns, Verbose, FollowSymlinks, PreserveTimestamps, PreservePermissions) - ✅ `ISyncStorage.SetLastModifiedAsync` / `SetPermissionsAsync` default interface methods - ✅ Symlink detection (`SyncItem.IsSymlink`) in Local and SFTP storage diff --git a/README.md b/README.md index 25e52f3..b01b9d5 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,14 @@ var result = await engine.SynchronizeAsync(); var resolver = new SmartConflictResolver( conflictHandler: async (analysis, ct) => { - // analysis contains: LocalSize, RemoteSize, LocalModified, RemoteModified, - // DetectedNewer, Recommendation, ReasonForRecommendation - Console.WriteLine($"Conflict: {analysis.Path}"); + // analysis contains: FilePath, LocalSize, RemoteSize, LocalModified, + // RemoteModified, NewerVersion, RecommendedResolution + Console.WriteLine($"Conflict: {analysis.FilePath}"); Console.WriteLine($" Local: {analysis.LocalModified}, Remote: {analysis.RemoteModified}"); - Console.WriteLine($" Recommendation: {analysis.Recommendation}"); + Console.WriteLine($" Recommendation: {analysis.RecommendedResolution}"); // Return user's choice - return analysis.Recommendation; + return analysis.RecommendedResolution; }, defaultResolution: ConflictResolution.Ask ); @@ -239,7 +239,7 @@ var plan = await engine.GetSyncPlanAsync(); Console.WriteLine($"Downloads: {plan.Downloads.Count}"); Console.WriteLine($"Uploads: {plan.Uploads.Count}"); -Console.WriteLine($"Deletes: {plan.Deletes.Count}"); +Console.WriteLine($"Deletes: {plan.DeleteCount}"); Console.WriteLine($"Conflicts: {plan.Conflicts.Count}"); foreach (var action in plan.Downloads) @@ -484,6 +484,17 @@ dotnet test dotnet pack --configuration Release ``` +## Samples + +The [`samples/`](samples/) directory contains a buildable console application demonstrating SharpSync features: + +```bash +cd samples/SharpSync.Samples.Console +dotnet run +``` + +See the [samples README](samples/README.md) for details. + ## Contributing 1. Fork the repository diff --git a/SharpSync.sln b/SharpSync.sln index c217644..6982596 100644 --- a/SharpSync.sln +++ b/SharpSync.sln @@ -19,20 +19,56 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpSync.Samples.Console", "samples\SharpSync.Samples.Console\SharpSync.Samples.Console.csproj", "{15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Debug|x64.ActiveCfg = Debug|Any CPU + {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Debug|x64.Build.0 = Debug|Any CPU + {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Debug|x86.ActiveCfg = Debug|Any CPU + {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Debug|x86.Build.0 = Debug|Any CPU {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Release|Any CPU.ActiveCfg = Release|Any CPU {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Release|Any CPU.Build.0 = Release|Any CPU + {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Release|x64.ActiveCfg = Release|Any CPU + {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Release|x64.Build.0 = Release|Any CPU + {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Release|x86.ActiveCfg = Release|Any CPU + {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0}.Release|x86.Build.0 = Release|Any CPU {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Debug|x64.ActiveCfg = Debug|Any CPU + {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Debug|x64.Build.0 = Debug|Any CPU + {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Debug|x86.ActiveCfg = Debug|Any CPU + {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Debug|x86.Build.0 = Debug|Any CPU {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Release|Any CPU.ActiveCfg = Release|Any CPU {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Release|Any CPU.Build.0 = Release|Any CPU + {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Release|x64.ActiveCfg = Release|Any CPU + {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Release|x64.Build.0 = Release|Any CPU + {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Release|x86.ActiveCfg = Release|Any CPU + {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5}.Release|x86.Build.0 = Release|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Debug|x64.ActiveCfg = Debug|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Debug|x64.Build.0 = Debug|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Debug|x86.ActiveCfg = Debug|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Debug|x86.Build.0 = Debug|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Release|Any CPU.Build.0 = Release|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Release|x64.ActiveCfg = Release|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Release|x64.Build.0 = Release|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Release|x86.ActiveCfg = Release|Any CPU + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -40,8 +76,9 @@ Global GlobalSection(NestedProjects) = preSolution {9E2AC590-0841-4E4D-A9D7-F6E51F69AFF0} = {E5F5D5D6-1234-5678-9ABC-DEF123456789} {0DC8A00C-5E88-47BF-9F05-B9666A0B49A5} = {F6A7B8C9-2345-6789-ABCD-EF2345678901} + {15DE9C10-6FA2-43BE-AA82-A77CD00C8D5D} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B5D7E9F1-3456-7890-CDEF-123456789012} EndGlobalSection -EndGlobal \ No newline at end of file +EndGlobal diff --git a/samples/README.md b/samples/README.md index 31ae82f..aa8a0ce 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,10 +1,14 @@ -# SharpSync Examples +# SharpSync Samples -This directory contains example code demonstrating how to use the SharpSync library. +This directory contains sample applications demonstrating how to use the SharpSync library. -## BasicSyncExample.cs +## Available Samples -A comprehensive example showing: +### 1. SharpSync.Samples.Console + +A comprehensive console application that demonstrates most SharpSync features in an interactive menu-driven interface. + +**Features demonstrated:** - **Basic sync setup** - Creating storage, database, filter, and sync engine - **Progress events** - Wiring up UI updates during sync @@ -16,32 +20,18 @@ A comprehensive example showing: - **Bandwidth throttling** - Limiting transfer speeds - **Sync options** - Configuring `ChecksumOnly`, `SizeOnly`, `PreserveTimestamps`, `PreservePermissions`, `FollowSymlinks`, `ExcludePatterns`, `TimeoutSeconds`, `UpdateExisting`, `ConflictResolution` override, and `Verbose` logging - **Smart conflict resolution** - Handling conflicts with UI prompts +- **OAuth2 authentication** - Browser-based OAuth2 flow for Nextcloud/OCIS -## ConsoleOAuth2Example.cs - -A reference implementation of `IOAuth2Provider` for console/headless applications: - -- **Browser-based OAuth2 flow** - Opens the system browser and listens on localhost for the callback -- **Authorization code exchange** - Exchanges the code for access and refresh tokens -- **Token refresh** - Refreshing expired tokens using the refresh token -- **Token validation** - Checking token validity before API calls -- **Nextcloud integration** - End-to-end example connecting to Nextcloud via WebDAV with OAuth2 -- **Cross-platform browser launch** - Works on Windows, macOS, and Linux - -## Usage - -This is a standalone example file, not a buildable project. To use it: +**To run:** +```bash +cd samples/SharpSync.Samples.Console +dotnet run +``` -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 +The application will present an interactive menu with options to: +1. Run a basic local-to-local sync demo using temporary directories +2. View an overview of all available `SyncOptions` +3. Run the OAuth2 Nextcloud sync example (requires a live Nextcloud server) ## Storage Options @@ -86,3 +76,12 @@ foreach (var op in history) { Console.WriteLine($"{op.ActionType}: {op.Path} ({op.Duration.TotalSeconds:F1}s)"); } ``` + +## Adding Your Own Samples + +Feel free to contribute additional samples demonstrating specific use cases: + +- Desktop application integration (WPF/WinUI/Avalonia) +- Background service for scheduled sync +- S3 or SFTP sync workflows +- Custom conflict resolution strategies diff --git a/samples/BasicSyncExample.cs b/samples/SharpSync.Samples.Console/BasicSyncExample.cs similarity index 77% rename from samples/BasicSyncExample.cs rename to samples/SharpSync.Samples.Console/BasicSyncExample.cs index 3476264..3f06e56 100644 --- a/samples/BasicSyncExample.cs +++ b/samples/SharpSync.Samples.Console/BasicSyncExample.cs @@ -1,312 +1,305 @@ -// ============================================================================= -// 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.UseRemote); - - // 5. Create sync engine - using var syncEngine = new SyncEngine( - localStorage, - remoteStorage, - database, - conflictResolver, - filter); - - // 6. Wire up events for UI updates - syncEngine.ProgressChanged += (sender, e) => { - Console.WriteLine($"[{e.Progress.Percentage:F0}%] {e.Operation}: {e.Progress.CurrentItem}"); - }; - - // Per-file byte-level progress for large file transfers - syncEngine.FileProgressChanged += (sender, e) => { - Console.WriteLine($" {e.Operation}: {e.Path} - {e.PercentComplete}% ({e.BytesTransferred}/{e.TotalBytes} bytes)"); - }; - - 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"); - } - - /// - /// Configure sync options for fine-grained control over synchronization behavior. - /// - public static async Task SyncWithOptionsAsync(ISyncEngine syncEngine) { - // Checksum-only mode: detect changes by file hash instead of timestamps. - // Useful when timestamps are unreliable (e.g., after restoring from backup). - var checksumOptions = new SyncOptions { ChecksumOnly = true }; - await syncEngine.SynchronizeAsync(checksumOptions); - - // Size-only mode: detect changes by file size only (fastest, least accurate). - // Good for quick checks when only large content changes matter. - var sizeOptions = new SyncOptions { SizeOnly = true }; - await syncEngine.SynchronizeAsync(sizeOptions); - - // Preserve timestamps and permissions across sync. - // Timestamps are set on the target after each file transfer. - // Permissions (Unix only) are preserved for Local and SFTP storage. - var preserveOptions = new SyncOptions { - PreserveTimestamps = true, - PreservePermissions = true - }; - await syncEngine.SynchronizeAsync(preserveOptions); - - // Skip symlink directories during sync. - // When false (default), symlink directories are not followed. - var symlinkOptions = new SyncOptions { FollowSymlinks = true }; - await syncEngine.SynchronizeAsync(symlinkOptions); - - // Per-sync exclude patterns (applied in addition to the engine-level SyncFilter). - // Useful for one-off syncs that need extra filtering without modifying the filter. - var excludeOptions = new SyncOptions { - ExcludePatterns = new List { "*.bak", "thumbs.db", "*.tmp" } - }; - await syncEngine.SynchronizeAsync(excludeOptions); - - // Timeout: cancel sync if it exceeds the given number of seconds. - var timeoutOptions = new SyncOptions { TimeoutSeconds = 300 }; - await syncEngine.SynchronizeAsync(timeoutOptions); - - // UpdateExisting=false: only sync new files, skip modifications to existing files. - var newOnlyOptions = new SyncOptions { UpdateExisting = false }; - await syncEngine.SynchronizeAsync(newOnlyOptions); - - // Override conflict resolution per-sync via options. - // This takes priority over the IConflictResolver passed to the engine constructor. - // Set to ConflictResolution.Ask to delegate to the resolver instead. - var conflictOptions = new SyncOptions { - ConflictResolution = ConflictResolution.UseLocal - }; - await syncEngine.SynchronizeAsync(conflictOptions); - - // Verbose logging: emits detailed Debug-level log messages for change detection, - // action processing, and phase completion. Requires an ILogger to be - // passed to the SyncEngine constructor. - var verboseOptions = new SyncOptions { Verbose = true }; - await syncEngine.SynchronizeAsync(verboseOptions); - - // Options can be combined freely. - var combinedOptions = new SyncOptions { - ChecksumOnly = true, - PreserveTimestamps = true, - ExcludePatterns = new List { "*.log" }, - TimeoutSeconds = 600, - Verbose = true - }; - var result = await syncEngine.SynchronizeAsync(combinedOptions); - Console.WriteLine($"Combined 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.FilePath}"); - Console.WriteLine($" Local: {analysis.LocalSize} bytes, modified {analysis.LocalModified}"); - Console.WriteLine($" Remote: {analysis.RemoteSize} bytes, modified {analysis.RemoteModified}"); - Console.WriteLine($" Recommendation: {analysis.RecommendedResolution}"); - Console.WriteLine($" Reason: {analysis.Reasoning}"); - - // 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, resolver, filter); - } -} +// ============================================================================= +// SharpSync Basic Usage Example +// ============================================================================= +// This file demonstrates how to use SharpSync for file synchronization. +// Each method shows a different feature of the library. +// ============================================================================= + +using Microsoft.Extensions.Logging; +using Oire.SharpSync.Core; +using Oire.SharpSync.Database; +using Oire.SharpSync.Storage; +using Oire.SharpSync.Sync; + +namespace SharpSync.Samples.Console; + +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.AddExclusionPattern("*.tmp"); + filter.AddExclusionPattern("*.log"); + filter.AddExclusionPattern(".git/**"); + filter.AddExclusionPattern("node_modules/**"); + + // 4. Create conflict resolver + var conflictResolver = new DefaultConflictResolver(ConflictResolution.UseRemote); + + // 5. Create sync engine + using var syncEngine = new SyncEngine( + localStorage, + remoteStorage, + database, + conflictResolver, + filter); + + // 6. Wire up events for UI updates + syncEngine.ProgressChanged += (sender, e) => { + System.Console.WriteLine($"[{e.Progress.Percentage:F0}%] {e.Operation}: {e.Progress.CurrentItem}"); + }; + + // Per-file byte-level progress for large file transfers + syncEngine.FileProgressChanged += (sender, e) => { + System.Console.WriteLine($" {e.Operation}: {e.Path} - {e.PercentComplete}% ({e.BytesTransferred}/{e.TotalBytes} bytes)"); + }; + + syncEngine.ConflictDetected += (sender, e) => { + System.Console.WriteLine($"Conflict: {e.Path}"); + }; + + // 7. Run synchronization + var result = await syncEngine.SynchronizeAsync(); + + System.Console.WriteLine($"Sync completed: {result.FilesSynchronized} files synchronized"); + } + + /// + /// Preview changes before syncing. + /// + public static async Task PreviewSyncAsync(ISyncEngine syncEngine) { + var plan = await syncEngine.GetSyncPlanAsync(); + + System.Console.WriteLine($"Uploads planned: {plan.Uploads.Count}"); + foreach (var upload in plan.Uploads) { + System.Console.WriteLine($" + {upload.Path} ({upload.Size} bytes)"); + } + + System.Console.WriteLine($"Downloads planned: {plan.Downloads.Count}"); + foreach (var download in plan.Downloads) { + System.Console.WriteLine($" - {download.Path} ({download.Size} bytes)"); + } + + System.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); + + System.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 ? "✓" : "✗"; + System.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)); + System.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)); + System.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(); + System.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"); + System.Console.WriteLine($"Folder sync: {folderResult.FilesSynchronized} files"); + + // Sync specific files + string[] filesToSync = ["config.json", "data/settings.xml"]; + var fileResult = await syncEngine.SyncFilesAsync(filesToSync); + System.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, ct); + if (syncEngine.State == SyncEngineState.Running) { + await syncEngine.PauseAsync(); + System.Console.WriteLine($"Sync paused. State: {syncEngine.State}"); + + // Do something while paused... + await Task.Delay(2000, ct); + + // Resume + await syncEngine.ResumeAsync(); + System.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); + System.Console.WriteLine($"Throttled sync completed: {result.FilesSynchronized} files"); + } + + /// + /// Configure sync options for fine-grained control over synchronization behavior. + /// + public static async Task SyncWithOptionsAsync(ISyncEngine syncEngine) { + // Checksum-only mode: detect changes by file hash instead of timestamps. + // Useful when timestamps are unreliable (e.g., after restoring from backup). + var checksumOptions = new SyncOptions { ChecksumOnly = true }; + await syncEngine.SynchronizeAsync(checksumOptions); + + // Size-only mode: detect changes by file size only (fastest, least accurate). + // Good for quick checks when only large content changes matter. + var sizeOptions = new SyncOptions { SizeOnly = true }; + await syncEngine.SynchronizeAsync(sizeOptions); + + // Preserve timestamps and permissions across sync. + // Timestamps are set on the target after each file transfer. + // Permissions (Unix only) are preserved for Local and SFTP storage. + var preserveOptions = new SyncOptions { + PreserveTimestamps = true, + PreservePermissions = true + }; + await syncEngine.SynchronizeAsync(preserveOptions); + + // Skip symlink directories during sync. + // When false (default), symlink directories are not followed. + var symlinkOptions = new SyncOptions { FollowSymlinks = true }; + await syncEngine.SynchronizeAsync(symlinkOptions); + + // Per-sync exclude patterns (applied in addition to the engine-level SyncFilter). + // Useful for one-off syncs that need extra filtering without modifying the filter. + var excludeOptions = new SyncOptions { + ExcludePatterns = new List { "*.bak", "thumbs.db", "*.tmp" } + }; + await syncEngine.SynchronizeAsync(excludeOptions); + + // Timeout: cancel sync if it exceeds the given number of seconds. + var timeoutOptions = new SyncOptions { TimeoutSeconds = 300 }; + await syncEngine.SynchronizeAsync(timeoutOptions); + + // UpdateExisting=false: only sync new files, skip modifications to existing files. + var newOnlyOptions = new SyncOptions { UpdateExisting = false }; + await syncEngine.SynchronizeAsync(newOnlyOptions); + + // Override conflict resolution per-sync via options. + // This takes priority over the IConflictResolver passed to the engine constructor. + // Set to ConflictResolution.Ask to delegate to the resolver instead. + var conflictOptions = new SyncOptions { + ConflictResolution = ConflictResolution.UseLocal + }; + await syncEngine.SynchronizeAsync(conflictOptions); + + // Verbose logging: emits detailed Debug-level log messages for change detection, + // action processing, and phase completion. Requires an ILogger to be + // passed to the SyncEngine constructor. + var verboseOptions = new SyncOptions { Verbose = true }; + await syncEngine.SynchronizeAsync(verboseOptions); + + // Options can be combined freely. + var combinedOptions = new SyncOptions { + ChecksumOnly = true, + PreserveTimestamps = true, + ExcludePatterns = new List { "*.log" }, + TimeoutSeconds = 600, + Verbose = true + }; + var result = await syncEngine.SynchronizeAsync(combinedOptions); + System.Console.WriteLine($"Combined 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: (analysis, ct) => { + // This callback is invoked for each conflict + System.Console.WriteLine($"Conflict: {analysis.FilePath}"); + System.Console.WriteLine($" Local: {analysis.LocalSize} bytes, modified {analysis.LocalModified}"); + System.Console.WriteLine($" Remote: {analysis.RemoteSize} bytes, modified {analysis.RemoteModified}"); + System.Console.WriteLine($" Recommendation: {analysis.RecommendedResolution}"); + + // In a real app, show a dialog and return user's choice + // For this example, accept the recommendation + return Task.FromResult(analysis.RecommendedResolution); + }, + defaultResolution: ConflictResolution.Ask); + + return new SyncEngine(localStorage, remoteStorage, database, resolver, filter); + } +} diff --git a/samples/ConsoleOAuth2Example.cs b/samples/SharpSync.Samples.Console/ConsoleOAuth2Example.cs similarity index 83% rename from samples/ConsoleOAuth2Example.cs rename to samples/SharpSync.Samples.Console/ConsoleOAuth2Example.cs index af634a8..7fb461b 100644 --- a/samples/ConsoleOAuth2Example.cs +++ b/samples/SharpSync.Samples.Console/ConsoleOAuth2Example.cs @@ -1,310 +1,305 @@ -// ============================================================================= -// SharpSync Console OAuth2 Provider Example -// ============================================================================= -// This file demonstrates how to implement IOAuth2Provider for a console or -// headless application that needs to authenticate with Nextcloud or OCIS -// via OAuth2. -// -// The flow: -// 1. Open the user's default browser to the authorization URL -// 2. Listen on a local HTTP endpoint for the OAuth2 callback -// 3. Exchange the authorization code for tokens -// 4. Return the tokens to SharpSync for use with WebDavStorage -// -// Required NuGet packages: -// - Oire.SharpSync -// - System.Net.Http (included in .NET 8+) -// ============================================================================= - -using System.Diagnostics; -using System.Net; -using System.Text; -using System.Text.Json; -using Oire.SharpSync.Auth; -using Oire.SharpSync.Core; -using Oire.SharpSync.Database; -using Oire.SharpSync.Storage; -using Oire.SharpSync.Sync; - -namespace YourApp; - -/// -/// A console-based OAuth2 provider that opens the system browser for -/// authorization and listens on localhost for the callback. -/// -/// -/// -/// This is a reference implementation. Production applications should: -/// -/// -/// Store tokens securely (e.g., Windows Credential Manager, macOS Keychain) -/// Handle token persistence across application restarts -/// Implement proper error handling for network failures -/// Use PKCE (Proof Key for Code Exchange) for public clients -/// -/// -public class ConsoleOAuth2Provider: IOAuth2Provider { - private readonly HttpClient _httpClient = new(); - - /// - public async Task AuthenticateAsync( - OAuth2Config config, - CancellationToken cancellationToken = default) { - // 1. Start local HTTP listener for the callback - using var listener = new HttpListener(); - listener.Prefixes.Add(config.RedirectUri.EndsWith('/') - ? config.RedirectUri - : config.RedirectUri + "/"); - listener.Start(); - - // 2. Build authorization URL - var scopes = string.Join(" ", config.Scopes); - var state = Guid.NewGuid().ToString("N"); - - var authUrl = $"{config.AuthorizeUrl}" - + $"?client_id={Uri.EscapeDataString(config.ClientId)}" - + $"&redirect_uri={Uri.EscapeDataString(config.RedirectUri)}" - + $"&response_type=code" - + $"&scope={Uri.EscapeDataString(scopes)}" - + $"&state={state}"; - - // 3. Open the user's default browser - Console.WriteLine("Opening browser for authentication..."); - Console.WriteLine($"If the browser doesn't open, navigate to:\n{authUrl}\n"); - OpenBrowser(authUrl); - - // 4. Wait for the OAuth2 callback - Console.WriteLine("Waiting for authorization callback..."); - var context = await listener.GetContextAsync().WaitAsync(cancellationToken); - var query = context.Request.QueryString; - var code = query["code"]; - var returnedState = query["state"]; - - // Send a success page to the browser - var responseBytes = Encoding.UTF8.GetBytes( - "

Authorization successful!

" - + "

You can close this tab and return to the application.

" - + ""); - context.Response.ContentType = "text/html"; - context.Response.ContentLength64 = responseBytes.Length; - await context.Response.OutputStream.WriteAsync(responseBytes, cancellationToken); - context.Response.Close(); - - // 5. Validate state parameter - if (returnedState != state) { - throw new InvalidOperationException( - "OAuth2 state mismatch. Possible CSRF attack."); - } - - if (string.IsNullOrEmpty(code)) { - var error = query["error"] ?? "unknown"; - var description = query["error_description"] ?? "No authorization code received."; - throw new InvalidOperationException( - $"OAuth2 authorization failed: {error} - {description}"); - } - - // 6. Exchange authorization code for tokens - return await ExchangeCodeForTokensAsync(config, code, cancellationToken); - } - - /// - public async Task RefreshTokenAsync( - OAuth2Config config, - string refreshToken, - CancellationToken cancellationToken = default) { - var parameters = new Dictionary { - ["grant_type"] = "refresh_token", - ["refresh_token"] = refreshToken, - ["client_id"] = config.ClientId, - }; - - if (!string.IsNullOrEmpty(config.ClientSecret)) { - parameters["client_secret"] = config.ClientSecret; - } - - var content = new FormUrlEncodedContent(parameters); - var response = await _httpClient.PostAsync(config.TokenUrl, content, cancellationToken); - var json = await response.Content.ReadAsStringAsync(cancellationToken); - - if (!response.IsSuccessStatusCode) { - throw new InvalidOperationException( - $"Token refresh failed ({response.StatusCode}): {json}"); - } - - return ParseTokenResponse(json); - } - - /// - public async Task ValidateTokenAsync( - OAuth2Result result, - CancellationToken cancellationToken = default) { - // Quick local check first - if (!result.IsValid) { - return false; - } - - // Check if token will expire within 30 seconds - if (result.WillExpireWithin(TimeSpan.FromSeconds(30))) { - return false; - } - - // Token appears valid based on expiry time. - // A production implementation could make a lightweight API call - // (e.g., GET /ocs/v2.php/cloud/user for Nextcloud) to verify - // the token is actually accepted by the server. - await Task.CompletedTask; // Placeholder for async API validation - return true; - } - - private async Task ExchangeCodeForTokensAsync( - OAuth2Config config, - string code, - CancellationToken cancellationToken) { - var parameters = new Dictionary { - ["grant_type"] = "authorization_code", - ["code"] = code, - ["redirect_uri"] = config.RedirectUri, - ["client_id"] = config.ClientId, - }; - - if (!string.IsNullOrEmpty(config.ClientSecret)) { - parameters["client_secret"] = config.ClientSecret; - } - - var content = new FormUrlEncodedContent(parameters); - var response = await _httpClient.PostAsync(config.TokenUrl, content, cancellationToken); - var json = await response.Content.ReadAsStringAsync(cancellationToken); - - if (!response.IsSuccessStatusCode) { - throw new InvalidOperationException( - $"Token exchange failed ({response.StatusCode}): {json}"); - } - - return ParseTokenResponse(json); - } - - private static OAuth2Result ParseTokenResponse(string json) { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - var accessToken = root.GetProperty("access_token").GetString() - ?? throw new InvalidOperationException("Missing access_token in response."); - - var expiresIn = root.TryGetProperty("expires_in", out var exp) - ? exp.GetInt32() - : 3600; - - var refreshToken = root.TryGetProperty("refresh_token", out var rt) - ? rt.GetString() - : null; - - var tokenType = root.TryGetProperty("token_type", out var tt) - ? tt.GetString() ?? "Bearer" - : "Bearer"; - - var userId = root.TryGetProperty("user_id", out var uid) - ? uid.GetString() - : null; - - return new OAuth2Result { - AccessToken = accessToken, - RefreshToken = refreshToken, - ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn), - TokenType = tokenType, - UserId = userId - }; - } - - private static void OpenBrowser(string url) { - try { - // Cross-platform browser launch - if (OperatingSystem.IsWindows()) { - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); - } else if (OperatingSystem.IsMacOS()) { - Process.Start("open", url); - } else { - Process.Start("xdg-open", url); - } - } catch { - // If browser launch fails, the user can manually navigate - // (the URL was already printed to the console) - } - } -} - -/// -/// Demonstrates using the ConsoleOAuth2Provider with WebDavStorage for Nextcloud sync. -/// -public static class OAuth2SyncExample { - public static async Task RunAsync() { - // --- Configuration --- - var nextcloudUrl = "https://cloud.example.com"; - var clientId = "your-oauth2-client-id"; - var redirectUri = "http://localhost:9090/"; - var localSyncPath = "/path/to/local/sync/folder"; - var dbPath = "/path/to/sync-state.db"; - - // 1. Create OAuth2 config for Nextcloud - var oauthConfig = OAuth2Config.ForNextcloud(nextcloudUrl, clientId, redirectUri); - Console.WriteLine($"Auth URL: {oauthConfig.AuthorizeUrl}"); - Console.WriteLine($"Token URL: {oauthConfig.TokenUrl}"); - - // 2. Create the OAuth2 provider and authenticate - var oauthProvider = new ConsoleOAuth2Provider(); - Console.WriteLine("Starting OAuth2 authentication..."); - var authResult = await oauthProvider.AuthenticateAsync(oauthConfig); - Console.WriteLine($"Authenticated as: {authResult.UserId ?? "unknown"}"); - Console.WriteLine($"Token expires at: {authResult.ExpiresAt:u}"); - - // 3. Create storage instances - var localStorage = new LocalFileStorage(localSyncPath); - var webDavUrl = $"{nextcloudUrl}/remote.php/dav/files/{authResult.UserId}/"; - var remoteStorage = new WebDavStorage(webDavUrl, oauth2Provider: oauthProvider); - - // 4. Create database and sync engine - var database = new SqliteSyncDatabase(dbPath); - await database.InitializeAsync(); - - var filter = new SyncFilter(); - var resolver = new SmartConflictResolver( - conflictHandler: async (analysis, ct) => { - Console.WriteLine($"Conflict on: {analysis.FileName}"); - Console.WriteLine($" Recommendation: {analysis.RecommendedResolution}"); - Console.WriteLine($" Reason: {analysis.Reasoning}"); - return analysis.RecommendedResolution; - }, - defaultResolution: ConflictResolution.Ask); - - using var engine = new SyncEngine( - localStorage, remoteStorage, database, resolver, filter); - - // 5. Wire up progress reporting - engine.ProgressChanged += (s, e) => - Console.WriteLine($"[{e.Progress.Percentage:F0}%] {e.Operation}: {e.Progress.CurrentItem}"); - engine.FileProgressChanged += (s, e) => - Console.WriteLine($" {e.Operation}: {e.Path} — {e.PercentComplete}%"); - - // 6. Preview and sync - var plan = await engine.GetSyncPlanAsync(); - Console.WriteLine($"\nSync plan: {plan.Summary}"); - - if (plan.HasChanges) { - Console.Write("Proceed with sync? [y/N] "); - if (Console.ReadLine()?.Trim().ToLowerInvariant() == "y") { - var result = await engine.SynchronizeAsync(); - Console.WriteLine($"Sync complete: {result.FilesSynchronized} files synced"); - } - } else { - Console.WriteLine("Everything is up to date."); - } - - // 7. Token refresh example (for long-running applications) - if (authResult.WillExpireWithin(TimeSpan.FromMinutes(5)) - && authResult.RefreshToken is not null) { - Console.WriteLine("Token expiring soon, refreshing..."); - var newResult = await oauthProvider.RefreshTokenAsync( - oauthConfig, authResult.RefreshToken); - Console.WriteLine($"Token refreshed, new expiry: {newResult.ExpiresAt:u}"); - } - } -} +// ============================================================================= +// SharpSync Console OAuth2 Provider Example +// ============================================================================= +// This file demonstrates how to implement IOAuth2Provider for a console or +// headless application that needs to authenticate with Nextcloud or OCIS +// via OAuth2. +// +// The flow: +// 1. Open the user's default browser to the authorization URL +// 2. Listen on a local HTTP endpoint for the OAuth2 callback +// 3. Exchange the authorization code for tokens +// 4. Return the tokens to SharpSync for use with WebDavStorage +// ============================================================================= + +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using Oire.SharpSync.Auth; +using Oire.SharpSync.Core; +using Oire.SharpSync.Database; +using Oire.SharpSync.Storage; +using Oire.SharpSync.Sync; + +namespace SharpSync.Samples.Console; + +/// +/// A console-based OAuth2 provider that opens the system browser for +/// authorization and listens on localhost for the callback. +/// +/// +/// +/// This is a reference implementation. Production applications should: +/// +/// +/// Store tokens securely (e.g., Windows Credential Manager, macOS Keychain) +/// Handle token persistence across application restarts +/// Implement proper error handling for network failures +/// Use PKCE (Proof Key for Code Exchange) for public clients +/// +/// +public class ConsoleOAuth2Provider: IOAuth2Provider { + private readonly HttpClient _httpClient = new(); + + /// + public async Task AuthenticateAsync( + OAuth2Config config, + CancellationToken cancellationToken = default) { + // 1. Start local HTTP listener for the callback + using var listener = new HttpListener(); + listener.Prefixes.Add(config.RedirectUri.EndsWith('/') + ? config.RedirectUri + : config.RedirectUri + "/"); + listener.Start(); + + // 2. Build authorization URL + var scopes = string.Join(" ", config.Scopes); + var state = Guid.NewGuid().ToString("N"); + + var authUrl = $"{config.AuthorizeUrl}" + + $"?client_id={Uri.EscapeDataString(config.ClientId)}" + + $"&redirect_uri={Uri.EscapeDataString(config.RedirectUri)}" + + $"&response_type=code" + + $"&scope={Uri.EscapeDataString(scopes)}" + + $"&state={state}"; + + // 3. Open the user's default browser + System.Console.WriteLine("Opening browser for authentication..."); + System.Console.WriteLine($"If the browser doesn't open, navigate to:\n{authUrl}\n"); + OpenBrowser(authUrl); + + // 4. Wait for the OAuth2 callback + System.Console.WriteLine("Waiting for authorization callback..."); + var context = await listener.GetContextAsync().WaitAsync(cancellationToken); + var query = context.Request.QueryString; + var code = query["code"]; + var returnedState = query["state"]; + + // Send a success page to the browser + var responseBytes = Encoding.UTF8.GetBytes( + "

Authorization successful!

" + + "

You can close this tab and return to the application.

" + + ""); + context.Response.ContentType = "text/html"; + context.Response.ContentLength64 = responseBytes.Length; + await context.Response.OutputStream.WriteAsync(responseBytes, cancellationToken); + context.Response.Close(); + + // 5. Validate state parameter + if (returnedState != state) { + throw new InvalidOperationException( + "OAuth2 state mismatch. Possible CSRF attack."); + } + + if (string.IsNullOrEmpty(code)) { + var error = query["error"] ?? "unknown"; + var description = query["error_description"] ?? "No authorization code received."; + throw new InvalidOperationException( + $"OAuth2 authorization failed: {error} - {description}"); + } + + // 6. Exchange authorization code for tokens + return await ExchangeCodeForTokensAsync(config, code, cancellationToken); + } + + /// + public async Task RefreshTokenAsync( + OAuth2Config config, + string refreshToken, + CancellationToken cancellationToken = default) { + var parameters = new Dictionary { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + ["client_id"] = config.ClientId, + }; + + if (!string.IsNullOrEmpty(config.ClientSecret)) { + parameters["client_secret"] = config.ClientSecret; + } + + var content = new FormUrlEncodedContent(parameters); + var response = await _httpClient.PostAsync(config.TokenUrl, content, cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) { + throw new InvalidOperationException( + $"Token refresh failed ({response.StatusCode}): {json}"); + } + + return ParseTokenResponse(json); + } + + /// + public async Task ValidateTokenAsync( + OAuth2Result result, + CancellationToken cancellationToken = default) { + // Quick local check first + if (!result.IsValid) { + return false; + } + + // Check if token will expire within 30 seconds + if (result.WillExpireWithin(TimeSpan.FromSeconds(30))) { + return false; + } + + // Token appears valid based on expiry time. + // A production implementation could make a lightweight API call + // (e.g., GET /ocs/v2.php/cloud/user for Nextcloud) to verify + // the token is actually accepted by the server. + await Task.CompletedTask; // Placeholder for async API validation + return true; + } + + private async Task ExchangeCodeForTokensAsync( + OAuth2Config config, + string code, + CancellationToken cancellationToken) { + var parameters = new Dictionary { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = config.RedirectUri, + ["client_id"] = config.ClientId, + }; + + if (!string.IsNullOrEmpty(config.ClientSecret)) { + parameters["client_secret"] = config.ClientSecret; + } + + var content = new FormUrlEncodedContent(parameters); + var response = await _httpClient.PostAsync(config.TokenUrl, content, cancellationToken); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) { + throw new InvalidOperationException( + $"Token exchange failed ({response.StatusCode}): {json}"); + } + + return ParseTokenResponse(json); + } + + private static OAuth2Result ParseTokenResponse(string json) { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var accessToken = root.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("Missing access_token in response."); + + var expiresIn = root.TryGetProperty("expires_in", out var exp) + ? exp.GetInt32() + : 3600; + + var refreshToken = root.TryGetProperty("refresh_token", out var rt) + ? rt.GetString() + : null; + + var tokenType = root.TryGetProperty("token_type", out var tt) + ? tt.GetString() ?? "Bearer" + : "Bearer"; + + var userId = root.TryGetProperty("user_id", out var uid) + ? uid.GetString() + : null; + + return new OAuth2Result { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresAt = DateTime.UtcNow.AddSeconds(expiresIn), + TokenType = tokenType, + UserId = userId + }; + } + + private static void OpenBrowser(string url) { + try { + // Cross-platform browser launch + if (OperatingSystem.IsWindows()) { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + } else if (OperatingSystem.IsMacOS()) { + Process.Start("open", url); + } else { + Process.Start("xdg-open", url); + } + } catch { + // If browser launch fails, the user can manually navigate + // (the URL was already printed to the console) + } + } +} + +/// +/// Demonstrates using the ConsoleOAuth2Provider with WebDavStorage for Nextcloud sync. +/// +public static class OAuth2SyncExample { + public static async Task RunAsync() { + // --- Configuration --- + var nextcloudUrl = "https://cloud.example.com"; + var clientId = "your-oauth2-client-id"; + var redirectUri = "http://localhost:9090/"; + var localSyncPath = "/path/to/local/sync/folder"; + var dbPath = "/path/to/sync-state.db"; + + // 1. Create OAuth2 config for Nextcloud + var oauthConfig = OAuth2Config.ForNextcloud(nextcloudUrl, clientId, redirectUri); + System.Console.WriteLine($"Auth URL: {oauthConfig.AuthorizeUrl}"); + System.Console.WriteLine($"Token URL: {oauthConfig.TokenUrl}"); + + // 2. Create the OAuth2 provider and authenticate + var oauthProvider = new ConsoleOAuth2Provider(); + System.Console.WriteLine("Starting OAuth2 authentication..."); + var authResult = await oauthProvider.AuthenticateAsync(oauthConfig); + System.Console.WriteLine($"Authenticated as: {authResult.UserId ?? "unknown"}"); + System.Console.WriteLine($"Token expires at: {authResult.ExpiresAt:u}"); + + // 3. Create storage instances + var localStorage = new LocalFileStorage(localSyncPath); + var webDavUrl = $"{nextcloudUrl}/remote.php/dav/files/{authResult.UserId}/"; + var remoteStorage = new WebDavStorage(webDavUrl, oauth2Provider: oauthProvider); + + // 4. Create database and sync engine + var database = new SqliteSyncDatabase(dbPath); + await database.InitializeAsync(); + + var filter = new SyncFilter(); + var resolver = new SmartConflictResolver( + conflictHandler: (analysis, ct) => { + System.Console.WriteLine($"Conflict on: {analysis.FilePath}"); + System.Console.WriteLine($" Recommendation: {analysis.RecommendedResolution}"); + return Task.FromResult(analysis.RecommendedResolution); + }, + defaultResolution: ConflictResolution.Ask); + + using var engine = new SyncEngine( + localStorage, remoteStorage, database, resolver, filter); + + // 5. Wire up progress reporting + engine.ProgressChanged += (s, e) => + System.Console.WriteLine($"[{e.Progress.Percentage:F0}%] {e.Operation}: {e.Progress.CurrentItem}"); + engine.FileProgressChanged += (s, e) => + System.Console.WriteLine($" {e.Operation}: {e.Path} — {e.PercentComplete}%"); + + // 6. Preview and sync + var plan = await engine.GetSyncPlanAsync(); + System.Console.WriteLine($"\nSync plan: {plan.TotalActions} actions ({plan.UploadCount} uploads, {plan.DownloadCount} downloads, {plan.ConflictCount} conflicts)"); + + if (plan.HasChanges) { + System.Console.Write("Proceed with sync? [y/N] "); + if (System.Console.ReadLine()?.Trim().ToLowerInvariant() == "y") { + var result = await engine.SynchronizeAsync(); + System.Console.WriteLine($"Sync complete: {result.FilesSynchronized} files synced"); + } + } else { + System.Console.WriteLine("Everything is up to date."); + } + + // 7. Token refresh example (for long-running applications) + if (authResult.WillExpireWithin(TimeSpan.FromMinutes(5)) + && authResult.RefreshToken is not null) { + System.Console.WriteLine("Token expiring soon, refreshing..."); + var newResult = await oauthProvider.RefreshTokenAsync( + oauthConfig, authResult.RefreshToken); + System.Console.WriteLine($"Token refreshed, new expiry: {newResult.ExpiresAt:u}"); + } + } +} diff --git a/samples/SharpSync.Samples.Console/Program.cs b/samples/SharpSync.Samples.Console/Program.cs new file mode 100644 index 0000000..15a6885 --- /dev/null +++ b/samples/SharpSync.Samples.Console/Program.cs @@ -0,0 +1,161 @@ +using Oire.SharpSync.Core; +using Oire.SharpSync.Database; +using Oire.SharpSync.Storage; +using Oire.SharpSync.Sync; + +namespace SharpSync.Samples.Console; + +public static class Program { + public static async Task Main(string[] args) { + System.Console.WriteLine("=== SharpSync Samples ==="); + System.Console.WriteLine(); + System.Console.WriteLine("1. Basic local-to-local sync (runs with temp directories)"); + System.Console.WriteLine("2. View all sync option examples (display only)"); + System.Console.WriteLine("3. OAuth2 Nextcloud sync (requires live server)"); + System.Console.WriteLine("4. Exit"); + System.Console.WriteLine(); + + while (true) { + System.Console.Write("Choose a sample [1-4]: "); + var choice = System.Console.ReadLine()?.Trim(); + + switch (choice) { + case "1": + await RunBasicLocalSyncAsync(); + break; + case "2": + ShowSyncOptionsOverview(); + break; + case "3": + await OAuth2SyncExample.RunAsync(); + break; + case "4": + return; + default: + System.Console.WriteLine("Invalid choice. Please enter 1-4."); + break; + } + + System.Console.WriteLine(); + } + } + + /// + /// Runs a real local-to-local sync using temporary directories. + /// + private static async Task RunBasicLocalSyncAsync() { + // Create temp directories for the demo + var tempBase = Path.Combine(Path.GetTempPath(), "SharpSync-Sample"); + var localPath = Path.Combine(tempBase, "local"); + var remotePath = Path.Combine(tempBase, "remote"); + var dbPath = Path.Combine(tempBase, "sync.db"); + + Directory.CreateDirectory(localPath); + Directory.CreateDirectory(remotePath); + + try { + // Create some sample files in "local" + await File.WriteAllTextAsync( + Path.Combine(localPath, "hello.txt"), + "Hello from SharpSync!"); + await File.WriteAllTextAsync( + Path.Combine(localPath, "notes.md"), + "# Notes\n\nThis file was synced by SharpSync."); + + // Create a sample file in "remote" + await File.WriteAllTextAsync( + Path.Combine(remotePath, "remote-file.txt"), + "This file exists only on the remote side."); + + System.Console.WriteLine($"Local dir: {localPath}"); + System.Console.WriteLine($"Remote dir: {remotePath}"); + System.Console.WriteLine(); + + // Set up storage, database, and engine + var localStorage = new LocalFileStorage(localPath); + var remoteStorage = new LocalFileStorage(remotePath); + var database = new SqliteSyncDatabase(dbPath); + await database.InitializeAsync(); + + var filter = new SyncFilter(); + var resolver = new DefaultConflictResolver(ConflictResolution.UseRemote); + + using var engine = new SyncEngine( + localStorage, remoteStorage, database, resolver, filter); + + engine.ProgressChanged += (_, e) => + System.Console.WriteLine( + $" [{e.Progress.Percentage:F0}%] {e.Operation}: {e.Progress.CurrentItem}"); + + // Preview the sync plan + var plan = await engine.GetSyncPlanAsync(); + System.Console.WriteLine( + $"Sync plan: {plan.TotalActions} actions ({plan.UploadCount} uploads, {plan.DownloadCount} downloads, {plan.ConflictCount} conflicts)"); + System.Console.WriteLine( + $" Uploads: {plan.Uploads.Count}, Downloads: {plan.Downloads.Count}"); + System.Console.WriteLine(); + + // Run sync + var result = await engine.SynchronizeAsync(); + System.Console.WriteLine( + $"Sync completed: {result.FilesSynchronized} files synchronized"); + + // Show resulting files + System.Console.WriteLine(); + System.Console.WriteLine("Files in local dir after sync:"); + foreach (var file in Directory.GetFiles(localPath)) { + System.Console.WriteLine($" {Path.GetFileName(file)}"); + } + + System.Console.WriteLine("Files in remote dir after sync:"); + foreach (var file in Directory.GetFiles(remotePath)) { + System.Console.WriteLine($" {Path.GetFileName(file)}"); + } + } finally { + // Cleanup + try { + Directory.Delete(tempBase, recursive: true); + System.Console.WriteLine(); + System.Console.WriteLine("Temp files cleaned up."); + } catch { + System.Console.WriteLine($"Note: Could not clean up {tempBase}"); + } + } + } + + /// + /// Displays an overview of the available SyncOptions. + /// + private static void ShowSyncOptionsOverview() { + System.Console.WriteLine(); + System.Console.WriteLine("=== SyncOptions Overview ==="); + System.Console.WriteLine(); + System.Console.WriteLine("SharpSync supports these options (combine as needed):"); + System.Console.WriteLine(); + System.Console.WriteLine( + " ChecksumOnly Detect changes by file hash instead of timestamps"); + System.Console.WriteLine( + " SizeOnly Detect changes by file size only (fastest)"); + System.Console.WriteLine( + " PreserveTimestamps Copy modification times to the target"); + System.Console.WriteLine( + " PreservePermissions Copy Unix permissions (Local/SFTP only)"); + System.Console.WriteLine( + " FollowSymlinks Follow symlink directories during sync"); + System.Console.WriteLine( + " ExcludePatterns Additional exclude globs for this sync run"); + System.Console.WriteLine( + " TimeoutSeconds Cancel sync after N seconds"); + System.Console.WriteLine( + " UpdateExisting Set to false to sync only new files"); + System.Console.WriteLine( + " MaxBytesPerSecond Bandwidth throttling (bytes/sec)"); + System.Console.WriteLine( + " ConflictResolution Override conflict strategy per sync"); + System.Console.WriteLine( + " Verbose Emit detailed Debug-level log messages"); + System.Console.WriteLine(); + System.Console.WriteLine( + "See BasicSyncExample.cs for code samples of each option."); + } +} diff --git a/samples/SharpSync.Samples.Console/SharpSync.Samples.Console.csproj b/samples/SharpSync.Samples.Console/SharpSync.Samples.Console.csproj new file mode 100644 index 0000000..a2e7e9c --- /dev/null +++ b/samples/SharpSync.Samples.Console/SharpSync.Samples.Console.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + enable + enable + false + + $(NoWarn);NETSDK1206 + + + + + + +