namespace ClinicBackupSync; sealed class FolderSync : IDisposable { readonly SyncSettings _settings; readonly UploadStateStore _state = new(); readonly BackupUploader _uploader = new(); readonly Lock _wakeGate = new(); readonly Lock _viewGate = new(); readonly List _activity = []; readonly Dictionary _failures = new(StringComparer.OrdinalIgnoreCase); readonly HashSet _priority = new(StringComparer.OrdinalIgnoreCase); readonly HashSet _knownFiles = new(StringComparer.OrdinalIgnoreCase); readonly Lock _watcherGate = new(); string? _uploading; long _uploadSent; long _uploadTotal; bool _knownFilesReady; FileSystemWatcher? _watcher; TaskCompletionSource _wake = NewWake(); public FolderSync(SyncSettings settings) { _settings = settings; } public event Action? StatusChanged; public event Action? ViewChanged; public event Action? NewFileFound; public event Action? ProgressChanged; public async Task RunAsync(CancellationToken cancellationToken) { SeedKnownFiles(); Report($"Watching {_settings.WatchFolder}"); AppLog.Write($"Watching {_settings.WatchFolder}"); AppLog.Write($"Upload URL {_settings.UploadUrl}"); while (!cancellationToken.IsCancellationRequested) { EnsureWatcher(); AnnounceNewFiles(); try { await UploadPendingAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } catch (Exception ex) { Report($"Upload failed: {ex.Message}"); AppLog.Write($"Scan failed: {ex.Message}"); } await WaitForChangeAsync(cancellationToken); } } public async Task RunOnceAsync(CancellationToken cancellationToken) { var deadline = DateTime.UtcNow.AddMinutes(10); while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested) { var pending = ListPending(); if (pending.Count == 0) { AppLog.Write("No new backup files."); return 0; } var uploadedAny = await UploadPendingAsync(cancellationToken); if (ListPending().Count == 0) { return uploadedAny ? 0 : 0; } if (!uploadedAny && pending.TrueForAll(IsStable)) { return 1; } await Task.Delay(TimeSpan.FromSeconds(_settings.RetrySeconds), cancellationToken); } return ListPending().Count == 0 ? 0 : 1; } public void ScanNow() { Wake(); } public void ApplyWatch(string folder, int intervalSeconds) { if (intervalSeconds < 5) { intervalSeconds = 5; } if (intervalSeconds > 24 * 60 * 60) { intervalSeconds = 24 * 60 * 60; } _settings.WatchFolder = folder; _settings.RetrySeconds = intervalSeconds; lock (_watcherGate) { _watcher?.Dispose(); _watcher = null; } SeedKnownFiles(); var message = Directory.Exists(folder) ? $"Watching {folder} every {intervalSeconds} seconds" : $"Folder not found: {folder}"; Report(message); AppLog.Write(message); Notify(); Wake(); } public void ClearActivity() { lock (_viewGate) { _activity.Clear(); } } public void RetryFile(string fileName) { if (string.IsNullOrWhiteSpace(fileName) || !string.Equals(fileName, Path.GetFileName(fileName), StringComparison.Ordinal)) { return; } if (ShouldIgnore(fileName) || !Directory.Exists(_settings.WatchFolder)) { return; } var root = Path.GetFullPath(_settings.WatchFolder); var path = Path.GetFullPath(Path.Combine(root, fileName)); var prefix = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; if (!path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || !File.Exists(path)) { return; } lock (_viewGate) { _priority.Add(path); } Report($"Retrying {fileName}"); Wake(); } public ActivityRow[] ActivitySnapshot() { lock (_viewGate) { return _activity.ToArray(); } } public FileRow[] ListFiles() { string? uploading; long uploadSent; long uploadTotal; Dictionary failures; lock (_viewGate) { uploading = _uploading; uploadSent = _uploadSent; uploadTotal = _uploadTotal; failures = new Dictionary(_failures, StringComparer.OrdinalIgnoreCase); } if (!Directory.Exists(_settings.WatchFolder)) { return []; } var rows = new List(); foreach (var path in Directory.EnumerateFiles(_settings.WatchFolder)) { var file = new FileInfo(path); if (ShouldIgnore(file.Name)) { continue; } var known = _state.Find(file); string status; string detail; if (uploading is not null && uploading.Equals(file.FullName, StringComparison.OrdinalIgnoreCase)) { status = "Uploading"; var percent = uploadTotal <= 0 ? 0 : (int)Math.Clamp(uploadSent * 100 / uploadTotal, 0, 100); detail = $"{percent}% sent"; } else if (known is { SameVersion: true }) { status = "Uploaded"; detail = known.Value.UploadedAtUtc.ToLocalTime().ToString("dd MMM yyyy HH:mm"); } else if (failures.TryGetValue(file.FullName, out var error)) { status = "Failed"; detail = error; } else if (known is not null) { status = "Waiting"; detail = "File changed since the last upload"; } else { status = "Waiting"; detail = WaitingReason(file); } rows.Add(new FileRow(file.Name, file.Length, file.LastWriteTime, status, detail)); } rows.Sort((left, right) => right.ModifiedLocal.CompareTo(left.ModifiedLocal)); return rows.ToArray(); } public void Dispose() { _watcher?.Dispose(); _uploader.Dispose(); } async Task UploadPendingAsync(CancellationToken cancellationToken) { var uploadedAny = false; var priority = TakePriority(); foreach (var file in priority ?? ListPending()) { cancellationToken.ThrowIfCancellationRequested(); file.Refresh(); if (!file.Exists || _state.IsUploaded(file)) { continue; } if (!IsStable(file)) { if (priority is not null) { var reason = WaitingReason(file); RememberFailure(file.FullName, reason); AddActivity("Failed", file.Name, reason); Report($"Upload failed: {file.Name}"); AppLog.Write($"Upload failed for {file.Name}: {reason}"); } continue; } SetUploading(file.FullName); Report($"Uploading {file.Name}"); AppLog.Write($"Uploading {file.FullName} ({file.Length} bytes)"); var progress = new Progress(sent => ReportProgress(file.Name, sent, file.Length)); try { var result = await _uploader.UploadAsync(file.FullName, _settings.UploadUrl, progress, cancellationToken); file.Refresh(); _state.MarkUploaded(file); ClearFailure(file.FullName); uploadedAny = true; AddActivity("Uploaded", file.Name, result); Report($"Uploaded {file.Name}"); AppLog.Write($"Uploaded {file.Name}: {result}"); } catch (Exception ex) when (ex is IOException or HttpRequestException or UnauthorizedAccessException) { RememberFailure(file.FullName, ex.Message); AddActivity("Failed", file.Name, ex.Message); Report($"Upload failed: {file.Name}"); AppLog.Write($"Upload failed for {file.Name}: {ex.Message}"); } finally { SetUploading(null); } } if (!uploadedAny) { var waiting = ListPending().Count; if (waiting == 0) { Report($"Watching {_settings.WatchFolder}"); } } Notify(); return uploadedAny; } string WaitingReason(FileInfo file) { file.Refresh(); if (!file.Exists || file.Length <= 0) { return "File is empty"; } if (DateTime.UtcNow - file.LastWriteTimeUtc < TimeSpan.FromSeconds(_settings.StableSeconds)) { return "Still being written"; } try { using var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.None); return stream.Length > 0 ? "Waiting to upload" : "File is empty"; } catch (IOException) { return "File is in use"; } catch (UnauthorizedAccessException) { return "File is in use"; } } void SetUploading(string? fullPath) { lock (_viewGate) { _uploading = fullPath; _uploadSent = 0; _uploadTotal = 0; } Notify(); } void ReportProgress(string fileName, long sent, long total) { lock (_viewGate) { if (_uploading is null) { return; } _uploadSent = sent; _uploadTotal = total; } ProgressChanged?.Invoke(new UploadProgress(fileName, sent, total)); } void RememberFailure(string fullPath, string message) { lock (_viewGate) { _failures[fullPath] = message; } } void ClearFailure(string fullPath) { lock (_viewGate) { _failures.Remove(fullPath); } } void AddActivity(string result, string fileName, string message) { lock (_viewGate) { _activity.Add(new ActivityRow(DateTime.Now, result, fileName, message)); if (_activity.Count > 400) { _activity.RemoveAt(0); } } Notify(); } void Notify() { ViewChanged?.Invoke(); } List? TakePriority() { lock (_viewGate) { if (_priority.Count == 0) { return null; } var files = _priority.Select(path => new FileInfo(path)).Where(file => file.Exists).ToList(); _priority.Clear(); return files; } } List ListPending() { if (!Directory.Exists(_settings.WatchFolder)) { return []; } var pending = new List(); foreach (var path in Directory.EnumerateFiles(_settings.WatchFolder)) { var file = new FileInfo(path); if (ShouldIgnore(file.Name) || _state.IsUploaded(file)) { continue; } pending.Add(file); } pending.Sort((left, right) => left.LastWriteTimeUtc.CompareTo(right.LastWriteTimeUtc)); return pending; } bool IsStable(FileInfo file) { file.Refresh(); if (!file.Exists || file.Length <= 0) { return false; } var quietFor = DateTime.UtcNow - file.LastWriteTimeUtc; if (quietFor < TimeSpan.FromSeconds(_settings.StableSeconds)) { return false; } try { using var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.None); return stream.Length == file.Length && stream.Length > 0; } catch (IOException) { return false; } catch (UnauthorizedAccessException) { return false; } } void SeedKnownFiles() { lock (_viewGate) { _knownFiles.Clear(); if (Directory.Exists(_settings.WatchFolder)) { foreach (var path in Directory.EnumerateFiles(_settings.WatchFolder)) { if (!ShouldIgnore(Path.GetFileName(path))) { _knownFiles.Add(path); } } } _knownFilesReady = true; } } void AnnounceNewFiles() { if (!Directory.Exists(_settings.WatchFolder)) { return; } var foundNew = false; foreach (var path in Directory.EnumerateFiles(_settings.WatchFolder)) { var added = NotePath(path); if (added is null) { continue; } foundNew = true; NewFileFound?.Invoke(added); } if (foundNew) { Notify(); } } void OnFolderChanged(string? fullPath) { Wake(); var added = NotePath(fullPath); if (added is null) { return; } Notify(); NewFileFound?.Invoke(added); } string? NotePath(string? fullPath) { if (string.IsNullOrWhiteSpace(fullPath)) { return null; } var name = Path.GetFileName(fullPath); if (ShouldIgnore(name)) { return null; } lock (_viewGate) { if (!_knownFilesReady || !_knownFiles.Add(fullPath)) { return null; } } return name; } void EnsureWatcher() { lock (_watcherGate) { if (_watcher is not null || !Directory.Exists(_settings.WatchFolder)) { return; } _watcher = new FileSystemWatcher(_settings.WatchFolder) { IncludeSubdirectories = false, NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime, InternalBufferSize = 64 * 1024, }; _watcher.Created += (_, eventArgs) => OnFolderChanged(eventArgs.FullPath); _watcher.Changed += (_, eventArgs) => OnFolderChanged(eventArgs.FullPath); _watcher.Renamed += (_, eventArgs) => { lock (_viewGate) { _knownFiles.Remove(eventArgs.OldFullPath); } OnFolderChanged(eventArgs.FullPath); }; _watcher.Error += (_, ex) => { AppLog.Write($"Folder watch restarted: {ex.GetException().Message}"); FileSystemWatcher? failed; lock (_watcherGate) { failed = _watcher; _watcher = null; } failed?.Dispose(); Wake(); }; _watcher.EnableRaisingEvents = true; } } async Task WaitForChangeAsync(CancellationToken cancellationToken) { Task wake; lock (_wakeGate) { wake = _wake.Task; } var delay = Task.Delay(TimeSpan.FromSeconds(_settings.RetrySeconds), cancellationToken); await Task.WhenAny(delay, wake); } void Wake() { lock (_wakeGate) { var current = _wake; _wake = NewWake(); current.TrySetResult(); } } static TaskCompletionSource NewWake() { return new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } static bool ShouldIgnore(string name) { if (name.StartsWith('~') || name.StartsWith('.')) { return true; } var extension = Path.GetExtension(name); return extension.Equals(".tmp", StringComparison.OrdinalIgnoreCase) || extension.Equals(".partial", StringComparison.OrdinalIgnoreCase); } void Report(string message) { StatusChanged?.Invoke(message); } }