using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; using Avalonia.Media.Imaging; using Avalonia.Platform.Storage; using Avalonia.Styling; using Avalonia.Threading; using Catalog.Communication.Abstractions; using Catalog.Communication.Models; using ImageCatalog; using Microsoft.Extensions.Logging; using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace ImageCatalog_2; public partial class AvaloniaMainWindow : Window { private readonly DataModel _model; private readonly IRaceUploadCommunicationClient _apiClient; private readonly ILogger _logger; private bool _isDarkTheme = false; public AvaloniaMainWindow( DataModel model, IRaceUploadCommunicationClient apiClient, ILogger logger) { InitializeComponent(); _model = model; _apiClient = apiClient; _logger = logger; DataContext = _model; Opened += (_, _) => SyncThemeStateFromCurrentTheme(); // Provide Avalonia dispatcher so DataModel can marshal UI updates _model.UiInvoker = action => Dispatcher.UIThread.Invoke(action); // Wire dialog events _model.SelectSourceFolderRequested += async (_, _) => { var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Seleziona cartella sorgente" }); if (folders.Count > 0) _model.SourcePath = folders[0].Path.LocalPath + Path.DirectorySeparatorChar; }; _model.SelectDestinationFolderRequested += async (_, _) => { var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Seleziona cartella destinazione" }); if (folders.Count > 0) _model.DestinationPath = folders[0].Path.LocalPath + Path.DirectorySeparatorChar; }; _model.SelectLogoFileRequested += async (_, _) => { var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Seleziona logo", FileTypeFilter = new[] { new FilePickerFileType("Immagini") { Patterns = new[] { "*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif" } } } }); if (files.Count > 0) { _model.LogoFile = files[0].Path.LocalPath; UpdateLogoPreview(_model.LogoFile); } }; _model.SelectModelsFolderRequested += async (_, _) => { var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Seleziona cartella modelli" }); if (folders.Count > 0) _model.ModelsFolderPath = folders[0].Path.LocalPath + Path.DirectorySeparatorChar; }; _model.SelectCsvOutputRequested += async (_, _) => { var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Salva CSV", DefaultExtension = "csv", FileTypeChoices = new[] { new FilePickerFileType("CSV") { Patterns = new[] { "*.csv" } } } }); if (file != null) _model.CsvOutputPath = file.Path.LocalPath; }; _model.SaveSettingsRequested += async (_, _) => { var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Salva impostazioni", DefaultExtension = "xml", FileTypeChoices = new[] { new FilePickerFileType("Setup") { Patterns = new[] { "*.xml" } } } }); if (file != null) await _model.SaveSettingsToFileAsync(file.Path.LocalPath); }; _model.LoadSettingsRequested += async (_, _) => { var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Carica impostazioni", FileTypeFilter = new[] { new FilePickerFileType("Setup") { Patterns = new[] { "*.xml" } } } }); if (files.Count > 0) await _model.LoadSettingsFromFileAsync(files[0].Path.LocalPath); }; _model.SelectColorRequested += (_, _) => { // Color is set by typing hex directly in the TextBox }; _model.SelectTransparentColorRequested += (_, _) => { // Color is set by typing hex directly in the TextBox }; _model.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(_model.LogoFile)) UpdateLogoPreview(_model.LogoFile); }; } private void ToggleTheme_Click(object? sender, RoutedEventArgs e) { _isDarkTheme = !_isDarkTheme; if (Avalonia.Application.Current != null) Avalonia.Application.Current.RequestedThemeVariant = _isDarkTheme ? ThemeVariant.Dark : ThemeVariant.Light; UpdateThemeToggleButtonContent(); } private void OpenSourceFolder_Click(object? sender, RoutedEventArgs e) => OpenInExplorer(_model.SourcePath); private void OpenDestinationFolder_Click(object? sender, RoutedEventArgs e) => OpenInExplorer(_model.DestinationPath); private void OpenModelsFolder_Click(object? sender, RoutedEventArgs e) => OpenInExplorer(_model.ModelsFolderPath); private void OpenCsvOutputFolder_Click(object? sender, RoutedEventArgs e) { var dir = Path.GetDirectoryName(_model.CsvOutputPath); OpenInExplorer(string.IsNullOrWhiteSpace(dir) ? _model.CsvOutputPath : dir); } private static void OpenInExplorer(string? path) { if (string.IsNullOrWhiteSpace(path)) return; path = path.Trim().Trim('"'); try { if (File.Exists(path)) System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\""); else if (Directory.Exists(path)) System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path, UseShellExecute = true }); } catch { } } private void UpdateLogoPreview(string? path) { var preview = this.FindControl("LogoPreview"); if (preview == null) return; if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) { preview.Source = null; return; } try { preview.Source = new Avalonia.Media.Imaging.Bitmap(path); } catch { preview.Source = null; } } private async void CreateRace_Click(object? sender, RoutedEventArgs e) { var outputBox = this.FindControl("ApiOutputTextBox"); var statusBlock = this.FindControl("ApiStatusTextBlock"); var createButton = this.FindControl("ApiCreateRaceButton"); var uploadButton = this.FindControl("ApiUploadButton"); if (outputBox is null || statusBlock is null || createButton is null || uploadButton is null) { return; } var login = _model.ApiLogin?.Trim() ?? string.Empty; var password = _model.ApiPassword ?? string.Empty; var descriptionRaw = _model.ApiRaceDescription?.Trim() ?? string.Empty; if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(descriptionRaw)) { statusBlock.Text = "Inserisci login, password e descrizione gara."; return; } if (!long.TryParse(_model.ApiRaceTypeId?.Trim(), out var tipoGaraId) || tipoGaraId <= 0) { statusBlock.Text = "Tipo gara non valido."; return; } createButton.IsEnabled = false; uploadButton.IsEnabled = false; statusBlock.Text = "Creazione gara in corso..."; outputBox.Text = string.Empty; try { var startDate = DateOnly.FromDateTime(_model.ApiRaceStartDate.Date); var endDate = DateOnly.FromDateTime((_model.ApiRaceEndDate == default ? _model.ApiRaceStartDate : _model.ApiRaceEndDate).Date); var sanitizedDescription = SanitizeRaceDescription(descriptionRaw); var loginResponse = await LoginAsync(login, password).ConfigureAwait(true); var saveResponse = await _apiClient.SaveRaceAsync( new RaceSaveRequest { IdGara = 0, Description = sanitizedDescription, StartDate = startDate, EndDate = endDate, TipoGaraId = tipoGaraId, EventoInLinea = _model.ApiEventoInLineaIndex, TipoIndicizzazione = _model.ApiTipoIndexValue, FreeEvent = _model.ApiFreeEventIndex, PathBase = _model.ApiPathBase?.Trim(), Localita = _model.ApiLocalita?.Trim(), }, CancellationToken.None); var raceId = ExtractRaceId(saveResponse.Body); if (raceId <= 0) { throw new InvalidOperationException("Impossibile ricavare id_gara dalla risposta di salvataggio."); } _model.ApiRaceId = raceId.ToString(); var createPointsResponse = await _apiClient.CreateRacePointsAsync(raceId, CancellationToken.None); var sb = new StringBuilder(); sb.AppendLine($"Login HTTP: {(int)loginResponse.StatusCode} {loginResponse.StatusCode}"); sb.AppendLine($"Save Gara HTTP: {(int)saveResponse.StatusCode} {saveResponse.StatusCode}"); sb.AppendLine($"Crea Punti HTTP: {(int)createPointsResponse.StatusCode} {createPointsResponse.StatusCode}"); sb.AppendLine($"id_gara: {raceId}"); sb.AppendLine(); sb.AppendLine("Gara creata e avvio creazione punti richiesto."); outputBox.Text = sb.ToString(); statusBlock.Text = "Gara creata."; } catch (Exception ex) { _logger.LogError(ex, "Race creation failed in Avalonia tab."); outputBox.Text = ex.ToString(); statusBlock.Text = "Errore durante la creazione gara."; } finally { createButton.IsEnabled = true; uploadButton.IsEnabled = true; } } private async void UploadProcessed_Click(object? sender, RoutedEventArgs e) { var outputBox = this.FindControl("ApiOutputTextBox"); var statusBlock = this.FindControl("ApiStatusTextBlock"); var createButton = this.FindControl("ApiCreateRaceButton"); var uploadButton = this.FindControl("ApiUploadButton"); if (outputBox is null || statusBlock is null || createButton is null || uploadButton is null) { return; } var login = _model.ApiLogin?.Trim() ?? string.Empty; var password = _model.ApiPassword ?? string.Empty; var racePathBase = _model.ApiPathBase?.Trim() ?? string.Empty; var remoteProcessedBase = _model.ApiRemoteProcessedBasePath?.Trim() ?? string.Empty; if (!long.TryParse(_model.ApiRaceId?.Trim(), out var raceId) || raceId <= 0) { statusBlock.Text = "id_gara non valido."; return; } if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password)) { statusBlock.Text = "Inserisci login e password."; return; } if (string.IsNullOrWhiteSpace(_model.DestinationPath) || !Directory.Exists(_model.DestinationPath)) { statusBlock.Text = "Cartella destinazione locale non valida."; return; } if (string.IsNullOrWhiteSpace(remoteProcessedBase)) { statusBlock.Text = "Inserisci il path base remoto per le foto processate."; return; } createButton.IsEnabled = false; uploadButton.IsEnabled = false; statusBlock.Text = "Upload foto processate in corso..."; try { await LoginAsync(login, password).ConfigureAwait(true); var files = Directory .EnumerateFiles(_model.DestinationPath, "*.*", SearchOption.AllDirectories) .Where(IsSupportedImage) .ToList(); if (files.Count == 0) { statusBlock.Text = "Nessuna immagine trovata in destinazione."; outputBox.Text = "Nessun file processato da inviare."; return; } var uploaded = 0; var sb = new StringBuilder(); sb.AppendLine($"File da inviare: {files.Count}"); foreach (var file in files) { var relativePath = Path.GetRelativePath(_model.DestinationPath, file); var relativeDir = Path.GetDirectoryName(relativePath) ?? string.Empty; var remotePath = CombineRemotePath(remoteProcessedBase, racePathBase, relativeDir); await using var stream = File.OpenRead(file); await _apiClient.UploadFileToReceiverAsync( new ReceiveFileUploadRequest { FileName = Path.GetFileName(file), FileStream = stream, DestinationPath = remotePath, OverwriteRemoteFile = true, }, CancellationToken.None).ConfigureAwait(true); uploaded++; if (uploaded % 20 == 0 || uploaded == files.Count) { statusBlock.Text = $"Upload foto: {uploaded}/{files.Count}"; } } sb.AppendLine($"Upload completato: {uploaded}/{files.Count}"); statusBlock.Text = "Creazione punti foto e indicizzazione in corso..."; await _apiClient.CreateRacePointsAsync(raceId, CancellationToken.None).ConfigureAwait(true); var pointIds = await LoadPointIdsWithRetryAsync(raceId, CancellationToken.None).ConfigureAwait(true); foreach (var pointId in pointIds) { await _apiClient.IndexRacePointAsync(pointId, CancellationToken.None).ConfigureAwait(true); } sb.AppendLine($"Punti foto indicizzati: {pointIds.Count}"); outputBox.Text = sb.ToString(); statusBlock.Text = "Upload e indicizzazione completati."; } catch (Exception ex) { _logger.LogError(ex, "Upload flow failed in Avalonia tab."); outputBox.Text = ex.ToString(); statusBlock.Text = "Errore durante upload/indicizzazione."; } finally { createButton.IsEnabled = true; uploadButton.IsEnabled = true; } } private async Task LoginAsync(string login, string password) { return await _apiClient.LoginAdminAsync( new AdminLoginRequest { Login = login, Password = password, Command = "check", }, CancellationToken.None).ConfigureAwait(false); } private async Task> LoadPointIdsWithRetryAsync(long raceId, CancellationToken cancellationToken) { const int maxAttempts = 10; for (var attempt = 1; attempt <= maxAttempts; attempt++) { var response = await _apiClient.GetRaceDetailAsync(raceId, cancellationToken).ConfigureAwait(false); var ids = ExtractPointIds(response.Body); if (ids.Count > 0) { return ids; } await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); } return new List(); } private static List ExtractPointIds(string html) { var ids = Regex .Matches(html ?? string.Empty, @"indexFoto\((\d+)\)", RegexOptions.IgnoreCase) .Select(m => long.TryParse(m.Groups[1].Value, out var value) ? value : 0L) .Where(v => v > 0) .Distinct() .ToList(); return ids; } private static string SanitizeRaceDescription(string value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } var cleaned = Regex.Replace(value, "[^A-Za-z0-9 _-]", " "); return Regex.Replace(cleaned, "\\s+", " ").Trim(); } private static string CombineRemotePath(string remoteBase, string racePathBase, string relativeDir) { var segments = new[] { remoteBase, racePathBase, relativeDir } .Where(s => !string.IsNullOrWhiteSpace(s)) .Select(s => s!.Replace('\\', '/').Trim('/')); var joined = string.Join('/', segments); return string.IsNullOrWhiteSpace(joined) ? "/" : joined + "/"; } private static bool IsSupportedImage(string filePath) { var extension = Path.GetExtension(filePath); if (string.IsNullOrWhiteSpace(extension)) { return false; } return extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) || extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) || extension.Equals(".png", StringComparison.OrdinalIgnoreCase) || extension.Equals(".bmp", StringComparison.OrdinalIgnoreCase) || extension.Equals(".gif", StringComparison.OrdinalIgnoreCase); } private async void SelectFaceExecutable_Click(object? sender, RoutedEventArgs e) { var executableBox = this.FindControl("FaceExecutablePathTextBox"); if (executableBox is null) { return; } var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Seleziona face_encoder.exe", FileTypeFilter = new[] { new FilePickerFileType("Eseguibile") { Patterns = new[] { "*.exe" } }, new FilePickerFileType("Tutti i file") { Patterns = new[] { "*.*" } } } }); if (files.Count > 0) { executableBox.Text = files[0].Path.LocalPath; _model.FaceExecutablePath = executableBox.Text; } } private async void SelectFaceOutputFolder_Click(object? sender, RoutedEventArgs e) { var outputBox = this.FindControl("FaceOutputFolderTextBox"); if (outputBox is null) { return; } var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Seleziona cartella output encodings" }); if (folders.Count > 0) { outputBox.Text = folders[0].Path.LocalPath; _model.FaceOutputFolderPath = outputBox.Text; } } private void OpenFaceExecutableFolder_Click(object? sender, RoutedEventArgs e) { var executableBox = this.FindControl("FaceExecutablePathTextBox"); if (executableBox is null) { return; } var path = executableBox.Text?.Trim(); if (string.IsNullOrWhiteSpace(path)) { return; } if (File.Exists(path)) { OpenInExplorer(path); return; } var dir = Path.GetDirectoryName(path); OpenInExplorer(string.IsNullOrWhiteSpace(dir) ? path : dir); } private void OpenFaceOutputFolder_Click(object? sender, RoutedEventArgs e) { var outputBox = this.FindControl("FaceOutputFolderTextBox"); if (outputBox is null) { return; } OpenInExplorer(outputBox.Text); } private async void RunFaceEncoder_Click(object? sender, RoutedEventArgs e) { var executableBox = this.FindControl("FaceExecutablePathTextBox"); var outputFolderBox = this.FindControl("FaceOutputFolderTextBox"); var outputLogBox = this.FindControl("FaceOutputTextBox"); var statusBlock = this.FindControl("FaceStatusTextBlock"); var runButton = this.FindControl("FaceRunButton"); if (executableBox is null || outputFolderBox is null || outputLogBox is null || statusBlock is null || runButton is null) { return; } var executablePath = executableBox.Text?.Trim().Trim('"') ?? string.Empty; var outputFolder = outputFolderBox.Text?.Trim().Trim('"') ?? string.Empty; var imagesFolder = (_model.DestinationPath ?? string.Empty).Trim().Trim('"'); _model.FaceExecutablePath = executablePath; _model.FaceOutputFolderPath = outputFolder; if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath)) { statusBlock.Text = "Percorso eseguibile non valido."; return; } if (string.IsNullOrWhiteSpace(imagesFolder) || !Directory.Exists(imagesFolder)) { statusBlock.Text = "Cartella Destinazione non valida."; return; } if (string.IsNullOrWhiteSpace(outputFolder)) { statusBlock.Text = "Inserisci la cartella di output."; return; } try { Directory.CreateDirectory(outputFolder); } catch (Exception ex) { _logger.LogError(ex, "Unable to create face output folder: {OutputFolder}", outputFolder); statusBlock.Text = "Impossibile creare la cartella di output."; return; } runButton.IsEnabled = false; statusBlock.Text = "Esecuzione face encoder in corso..."; outputLogBox.Text = string.Empty; var outputLines = new StringBuilder(); var errorLines = new StringBuilder(); try { var imagesFolderArg = NormalizeDirectoryPathArgument(imagesFolder); var outputFolderArg = NormalizeDirectoryPathArgument(outputFolder); Console.WriteLine($"[FaceAI] Command: \"{executablePath}\" --images \"{imagesFolderArg}\" --out \"{outputFolderArg}\""); var processStartInfo = new ProcessStartInfo { FileName = executablePath, WorkingDirectory = Path.GetDirectoryName(executablePath) ?? Environment.CurrentDirectory, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, }; processStartInfo.ArgumentList.Add("--images"); processStartInfo.ArgumentList.Add(imagesFolderArg); processStartInfo.ArgumentList.Add("--out"); processStartInfo.ArgumentList.Add(outputFolderArg); using var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true }; process.OutputDataReceived += (_, args) => { if (string.IsNullOrWhiteSpace(args.Data)) { return; } lock (outputLines) { outputLines.AppendLine(args.Data); } Console.WriteLine(args.Data); }; process.ErrorDataReceived += (_, args) => { if (string.IsNullOrWhiteSpace(args.Data)) { return; } lock (errorLines) { errorLines.AppendLine(args.Data); } Console.Error.WriteLine(args.Data); }; if (!process.Start()) { throw new InvalidOperationException("Avvio face_encoder.exe fallito."); } process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync().ConfigureAwait(true); var summary = new StringBuilder(); summary.AppendLine($"Exit code: {process.ExitCode}"); if (outputLines.Length > 0) { summary.AppendLine(); summary.AppendLine("STDOUT:"); summary.Append(outputLines); } if (errorLines.Length > 0) { summary.AppendLine(); summary.AppendLine("STDERR:"); summary.Append(errorLines); } outputLogBox.Text = summary.ToString(); if (process.ExitCode == 0) { statusBlock.Text = "Face encoder completato."; } else { statusBlock.Text = $"Face encoder terminato con errore (code {process.ExitCode})."; } } catch (Exception ex) { _logger.LogError(ex, "Face encoder execution failed."); Console.Error.WriteLine(ex); outputLogBox.Text = ex.ToString(); statusBlock.Text = "Errore durante esecuzione face encoder."; } finally { runButton.IsEnabled = true; } } private static string NormalizeDirectoryPathArgument(string value) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } var normalized = value.Trim().Trim('"'); var root = Path.GetPathRoot(normalized); if (!string.IsNullOrEmpty(root) && normalized.Length > root.Length) { normalized = normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } return normalized; } private void SyncThemeStateFromCurrentTheme() { var actualVariant = ActualThemeVariant; _isDarkTheme = actualVariant == ThemeVariant.Dark; UpdateThemeToggleButtonContent(); } private void UpdateThemeToggleButtonContent() { var toggleButton = this.FindControl("ThemeToggleButton"); if (toggleButton is null) { return; } toggleButton.Content = _isDarkTheme ? "☀" : "🌙"; } private static long ExtractRaceId(string html) { if (string.IsNullOrWhiteSpace(html)) { return 0; } var inputMatch = Regex.Match( html, "id=\\\"id_gara\\\"[^>]*value=\\\"(?\\d+)\\\"", RegexOptions.IgnoreCase); if (inputMatch.Success && long.TryParse(inputMatch.Groups["id"].Value, out var idFromInput)) { return idFromInput; } var labelMatch = Regex.Match(html, "Descrizione \\(id: (?\\d+)\\)", RegexOptions.IgnoreCase); return labelMatch.Success && long.TryParse(labelMatch.Groups["id"].Value, out var idFromLabel) ? idFromLabel : 0; } }