diff --git a/.vscode/launch.json b/.vscode/launch.json index 0af4af3..afbb38c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,6 +11,17 @@ "cwd": "${workspaceFolder:Catalog}/imagecatalog", "stopAtEntry": false, "console": "internalConsole" + }, + { + "name": "CatalogLite Avalonia", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build CatalogLite Avalonia", + "program": "${workspaceFolder:Catalog}/CatalogLite/bin/Debug/net10.0/CatalogLite.exe", + "args": [], + "cwd": "${workspaceFolder:Catalog}/CatalogLite", + "stopAtEntry": false, + "console": "internalConsole" } ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 22c598b..791bec6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,6 +13,19 @@ ], "problemMatcher": "$msCompile", "group": "build" + }, + { + "label": "build CatalogLite Avalonia", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder:Catalog}/CatalogLite/CatalogLite.csproj", + "--configuration", + "Debug" + ], + "problemMatcher": "$msCompile", + "group": "build" } ] } \ No newline at end of file diff --git a/CatalogLite/Assets/Config.xml b/CatalogLite/Assets/Config.xml new file mode 100644 index 0000000..9b0fa7d --- /dev/null +++ b/CatalogLite/Assets/Config.xml @@ -0,0 +1,227 @@ + + + + MiniatureModalita + Text + + + DirSorgente + + + + DirDestinazione + + + + MiniatureCrea + True + + + MiniatureSuffisso + tn_ + + + MiniatureAltezza + 350 + + + MiniatureLarghezza + 350 + + + FontDimensioneMiniatura + 20 + + + CompressioneJpegMiniatura + 60 + + + MiniatureAddOrario + False + + + NomeMiniatura + False + + + MiniatureAddScritta + True + + + TempoSmall + False + + + NumTempoSmall + False + + + FotoCodice + + + + FotoAltezza + 2560 + + + FotoLarghezza + 2560 + + + FotoDimOriginali + False + + + CompressioneJpeg + 90 + + + FontDimensione + 50 + + + FontNome + Verdana + + + FontBold + True + + + ColoreTestoRGB + #FA7B0A + + + TestoTesto + + + + TestoVerticale + + + + TestoTrasparente + 0 + + + TestoMargine + 8 + + + TestoPosizione + Basso + + + TestoAllineamento + Centro + + + GrandezzaVerticale + 18 + + + MargineVerticale + 6 + + + MarchioAltezza + 250 + + + MarchioLarghezza + 250 + + + MarchioMargine + 130 + + + MarchioAllOrizzontale + Destra + + + MarchioAllVerticale + Alto + + + MarchioTrasparenza + 100 + + + MarchioAggiungi + True + + + ColoreTrasparente + #FFFFFF + + + UsaColoreTrasparente + True + + + ImageLibrary + ImageSharp + + + GeneraleForzaJpg + True + + + GeneraleRotazioneAutomatica + True + + + DirSottoDirectory + True + + + TempoGara + False + + + Orario + False + + + EtichettaOrario + + + + DataFoto + False + + + NumeroFoto + False + + + GeneraleSovrascriviFile + False + + + DirDividiNumFile + 300 + + + DirDividiSuffisso + + + + DirDividiNumCifre + 2 + + + ChunkSize + 0 + + + ThreadsCount + 0 + + + DataPartenza + 17/02/2026 09:35:25 + + \ No newline at end of file diff --git a/CatalogLite/Assets/Logo_RUS_ETS_tricolore_OK.png b/CatalogLite/Assets/Logo_RUS_ETS_tricolore_OK.png new file mode 100644 index 0000000..1d4d8bf Binary files /dev/null and b/CatalogLite/Assets/Logo_RUS_ETS_tricolore_OK.png differ diff --git a/CatalogLite/Assets/testConfig.xml b/CatalogLite/Assets/testConfig.xml new file mode 100644 index 0000000..cf735d0 --- /dev/null +++ b/CatalogLite/Assets/testConfig.xml @@ -0,0 +1,336 @@ + + + + MiniatureModalita + RaceTime + + + MiniatureCrea + True + + + MiniatureSuffisso + tn_ + + + MiniatureAltezza + 350 + + + MiniatureLarghezza + 350 + + + FontDimensioneMiniatura + 48 + + + CompressioneJpegMiniatura + 25 + + + MiniatureAddOrario + False + + + NomeMiniatura + False + + + MiniatureAddScritta + False + + + TempoSmall + True + + + NumTempoSmall + False + + + FotoCodice + + + + FotoAltezza + 2560 + + + FotoLarghezza + 2560 + + + FotoDimOriginali + False + + + CompressioneJpeg + 90 + + + FontDimensione + 22 + + + FontNome + Verdana + + + FontBold + True + + + ColoreTestoRGB + #FEC005 + + + TestoTesto + MARATONINA DI VINCI -1 FEBBRAIO 2026 + + + TestoVerticale + MARATONINA DI VINCI +1 FEBBRAIO 2026 + + + TestoTrasparente + 0 + + + TestoMargine + 8 + + + TestoPosizione + Basso + + + TestoAllineamento + Centro + + + GrandezzaVerticale + 18 + + + MargineVerticale + 6 + + + MarchioFile + K:\various\catalogtest\Logo.jpg + + + MarchioAltezza + 470 + + + MarchioLarghezza + 470 + + + MarchioMargine + 350 + + + MarchioAllOrizzontale + Destra + + + MarchioAllVerticale + Alto + + + MarchioTrasparenza + 100 + + + MarchioAggiungi + True + + + ColoreTrasparente + #FFFFFF + + + UsaColoreTrasparente + True + + + ImageLibrary + System.Graphics + + + GeneraleForzaJpg + True + + + GeneraleRotazioneAutomatica + True + + + DirSottoDirectory + True + + + TempoGara + True + + + Orario + False + + + EtichettaOrario + TEMPO : + + + DataFoto + False + + + NumeroFoto + False + + + GeneraleSovrascriviFile + True + + + DirDividiNumFile + 300 + + + DirDividiSuffisso + + + + DirDividiNumCifre + 2 + + + ChunkSize + 200 + + + ThreadsCount + 10 + + + DataPartenza + 01/02/2026 20:30:48 + + + AI_EstraiNumeri + True + + + AI_CartellaModelli + K:\vs\AIFotoONLUS\models\\ + + + AI_PercorsoCsv + K:\various\catalogtest\aioutput\test2.csv + + + AI_UsaGpuNumeri + True + + + AI_IncludiThumbnailNumeri + False + + + AI_LivelloCaricoNumeri + 3 + + + AI_FaceExecutablePath + K:\various\regalamiunsorriso\bin\Face_Recognition_Windows\ + + + AI_FaceOutputFolderPath + K:\various\catalogtest\aioutput\ + + + AI_FaceRecursive + True + + + AI_FaceIncludeThumbnails + False + + + AI_FaceParallelism + 3 + + + AI_FaceMinSize + 35 + + + AI_FaceUpsample + False + + + AI_FaceMatcherExecutablePath + K:\various\regalamiunsorriso\bin\Face_Recognition_Windows\face_matcher.exe + + + AI_FaceMatcherTolerance + 0,75 + + + RaceUpload_Login + + + + RaceUpload_Password + + + + RaceUpload_Description + + + + RaceUpload_TipoGaraId + 1 + + + RaceUpload_StartDate + 12/03/2026 00:00:00 + + + RaceUpload_EndDate + 12/03/2026 00:00:00 + + + RaceUpload_PathBase + + + + RaceUpload_Localita + + + + RaceUpload_EventoInLinea + 0 + + + RaceUpload_TipoIndex + 1 + + + RaceUpload_FreeEvent + 0 + + + RaceUpload_LastRaceId + + + + RaceUpload_RemoteProcessedBasePath + + + \ No newline at end of file diff --git a/CatalogLite/AsyncCommand.cs b/CatalogLite/AsyncCommand.cs index fcc9fe7..d030d67 100644 --- a/CatalogLite/AsyncCommand.cs +++ b/CatalogLite/AsyncCommand.cs @@ -1,3 +1,4 @@ +using Avalonia.Threading; using System.Windows.Input; namespace CatalogLite; @@ -38,5 +39,14 @@ public sealed class AsyncCommand : ICommand } } - public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + public void RaiseCanExecuteChanged() + { + if (Dispatcher.UIThread.CheckAccess()) + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + return; + } + + Dispatcher.UIThread.Post(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty)); + } } \ No newline at end of file diff --git a/CatalogLite/CatalogConfigurationLoader.cs b/CatalogLite/CatalogConfigurationLoader.cs index f7652c9..a4e4a02 100644 --- a/CatalogLite/CatalogConfigurationLoader.cs +++ b/CatalogLite/CatalogConfigurationLoader.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Reflection; using System.Xml.Linq; using MaddoShared; using SixLabors.ImageSharp; @@ -8,6 +9,9 @@ namespace CatalogLite; public sealed class CatalogConfigurationLoader { + private const string EmbeddedConfigResourceName = "CatalogLite.Assets.Config.xml"; + private const string EmbeddedLogoResourceName = "CatalogLite.Assets.Logo_RUS_ETS_tricolore_OK.png"; + public CatalogLiteConfiguration Load(string filePath, PicSettings picSettings) { if (string.IsNullOrWhiteSpace(filePath)) @@ -21,7 +25,7 @@ public sealed class CatalogConfigurationLoader } var values = ConfigurationValues.Load(filePath); - ApplyPicSettings(values, picSettings); + ApplyPicSettings(values, picSettings); var sourcePath = LiteCatalogViewModel.NormalizeDirectoryPath(values.GetString("DirSorgente")); var destinationPath = LiteCatalogViewModel.NormalizeDirectoryPath(values.GetString("DirDestinazione")); @@ -41,6 +45,32 @@ public sealed class CatalogConfigurationLoader }; } + public CatalogLiteConfiguration LoadEmbedded(PicSettings picSettings) + { + using var configStream = OpenEmbeddedResource(EmbeddedConfigResourceName); + var values = ConfigurationValues.Load(configStream); + ApplyPicSettings(values, picSettings); + picSettings.LogoData = ReadAllBytes(OpenEmbeddedResource(EmbeddedLogoResourceName)); + picSettings.LogoNomeFile = string.Empty; + + var sourcePath = LiteCatalogViewModel.NormalizeDirectoryPath(values.GetString("DirSorgente")); + var destinationPath = LiteCatalogViewModel.NormalizeDirectoryPath(values.GetString("DirDestinazione")); + + picSettings.DirectorySorgente = sourcePath; + picSettings.DirectoryDestinazione = destinationPath; + picSettings.DestDir = string.IsNullOrWhiteSpace(destinationPath) + ? new DirectoryInfo(Environment.CurrentDirectory) + : new DirectoryInfo(destinationPath); + + return new CatalogLiteConfiguration + { + FilePath = "Configurazione incorporata", + SourcePath = sourcePath, + DestinationPath = destinationPath, + Options = BuildOptions(values, sourcePath, destinationPath) + }; + } + public static ImageCreationService.Options CloneOptions(ImageCreationService.Options options, string sourcePath, string destinationPath) { return new ImageCreationService.Options @@ -150,6 +180,22 @@ public sealed class CatalogConfigurationLoader return string.Equals(values.GetString("MiniatureModalita"), mode, StringComparison.OrdinalIgnoreCase); } + private static Stream OpenEmbeddedResource(string resourceName) + { + var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName); + return stream ?? throw new InvalidOperationException($"Risorsa incorporata non trovata: {resourceName}"); + } + + private static byte[] ReadAllBytes(Stream stream) + { + using (stream) + { + using var memoryStream = new MemoryStream(); + stream.CopyTo(memoryStream); + return memoryStream.ToArray(); + } + } + private static Rgba32 ParseColor(string value, Rgba32 fallback) { if (string.IsNullOrWhiteSpace(value)) @@ -193,7 +239,13 @@ public sealed class CatalogConfigurationLoader public static ConfigurationValues Load(string filePath) { - var document = XDocument.Load(filePath); + using var stream = File.OpenRead(filePath); + return Load(stream); + } + + public static ConfigurationValues Load(Stream stream) + { + var document = XDocument.Load(stream); var values = document .Descendants("Setup") .Where(element => element.Element("Nome") is not null) diff --git a/CatalogLite/CatalogLite.csproj b/CatalogLite/CatalogLite.csproj index 54121b7..be2b0bc 100644 --- a/CatalogLite/CatalogLite.csproj +++ b/CatalogLite/CatalogLite.csproj @@ -25,6 +25,11 @@ + + + + + diff --git a/CatalogLite/LiteCatalogViewModel.cs b/CatalogLite/LiteCatalogViewModel.cs index 0a4c8c6..656e4d2 100644 --- a/CatalogLite/LiteCatalogViewModel.cs +++ b/CatalogLite/LiteCatalogViewModel.cs @@ -12,10 +12,9 @@ public sealed class LiteCatalogViewModel : ViewModelBase private readonly ILogger _logger; private CatalogLiteConfiguration? _configuration; private CancellationTokenSource? _processingTokenSource; - private string _configurationPath = string.Empty; private string _sourcePath = string.Empty; private string _destinationPath = string.Empty; - private string _processingStatus = "Carica una configurazione XML."; + private string _processingStatus = "Caricamento configurazione incorporata..."; private string _speedCounter = "-"; private int _processedImagesCount; private int _totalImagesCount; @@ -36,36 +35,23 @@ public sealed class LiteCatalogViewModel : ViewModelBase _imageProcessingCoordinator = imageProcessingCoordinator; _logger = logger; - LoadConfigurationCommand = new AsyncCommand(RequestLoadConfigurationAsync, () => !IsProcessing); SelectSourceFolderCommand = new AsyncCommand(RequestSourceFolderAsync, () => !IsProcessing); SelectDestinationFolderCommand = new AsyncCommand(RequestDestinationFolderAsync, () => !IsProcessing); StartProcessingCommand = new AsyncCommand(StartProcessingAsync, CanStartProcessing); StopProcessingCommand = new AsyncCommand(StopProcessingAsync, () => IsProcessing); } - public event EventHandler? LoadConfigurationRequested; public event EventHandler? SelectSourceFolderRequested; public event EventHandler? SelectDestinationFolderRequested; public event EventHandler? ShowMessageRequested; public Action? UiInvoker { get; set; } - public AsyncCommand LoadConfigurationCommand { get; } public AsyncCommand SelectSourceFolderCommand { get; } public AsyncCommand SelectDestinationFolderCommand { get; } public AsyncCommand StartProcessingCommand { get; } public AsyncCommand StopProcessingCommand { get; } - public string ConfigurationPath - { - get => _configurationPath; - private set - { - _configurationPath = value; - NotifyPropertyChanged(); - } - } - public string SourcePath { get => _sourcePath; @@ -77,6 +63,26 @@ public sealed class LiteCatalogViewModel : ViewModelBase } } + public string HorizontalText + { + get => _picSettings.TestoFirmaStart ?? string.Empty; + set + { + _picSettings.TestoFirmaStart = value ?? string.Empty; + NotifyPropertyChanged(); + } + } + + public string VerticalText + { + get => _picSettings.TestoFirmaStartV ?? string.Empty; + set + { + _picSettings.TestoFirmaStartV = value ?? string.Empty; + NotifyPropertyChanged(); + } + } + public string DestinationPath { get => _destinationPath; @@ -159,25 +165,24 @@ public sealed class LiteCatalogViewModel : ViewModelBase } } - public async Task LoadConfigurationFromFileAsync(string filePath) + public void LoadEmbeddedConfiguration() { try { - var configuration = await Task.Run(() => _configurationLoader.Load(filePath, _picSettings)).ConfigureAwait(false); + var configuration = _configurationLoader.LoadEmbedded(_picSettings); - RunOnUiThread(() => - { - _configuration = configuration; - ConfigurationPath = configuration.FilePath; - SourcePath = configuration.SourcePath; - DestinationPath = configuration.DestinationPath; - ResetProgress("Configurazione caricata."); - }); + _configuration = configuration; + SourcePath = configuration.SourcePath; + DestinationPath = configuration.DestinationPath; + NotifyPropertyChanged(nameof(HorizontalText)); + NotifyPropertyChanged(nameof(VerticalText)); + ResetProgress("Configurazione incorporata caricata."); } catch (Exception ex) { - _logger.LogError(ex, "Errore durante il caricamento della configurazione"); - ShowMessage("Configurazione", $"Impossibile caricare la configurazione: {ex.GetBaseException().Message}"); + _logger.LogError(ex, "Errore durante il caricamento della configurazione incorporata"); + ProcessingStatus = "Errore caricamento configurazione incorporata."; + ShowMessage("Configurazione", $"Impossibile caricare la configurazione incorporata: {ex.GetBaseException().Message}"); } } @@ -192,12 +197,6 @@ public sealed class LiteCatalogViewModel : ViewModelBase return trimmed + Path.DirectorySeparatorChar; } - private Task RequestLoadConfigurationAsync() - { - LoadConfigurationRequested?.Invoke(this, EventArgs.Empty); - return Task.CompletedTask; - } - private Task RequestSourceFolderAsync() { SelectSourceFolderRequested?.Invoke(this, EventArgs.Empty); @@ -222,7 +221,7 @@ public sealed class LiteCatalogViewModel : ViewModelBase { if (_configuration is null) { - ShowMessage("Configurazione", "Carica prima una configurazione XML."); + ShowMessage("Configurazione", "La configurazione incorporata non e' disponibile."); return; } @@ -340,7 +339,6 @@ public sealed class LiteCatalogViewModel : ViewModelBase private void RaiseCommandStates() { - LoadConfigurationCommand.RaiseCanExecuteChanged(); SelectSourceFolderCommand.RaiseCanExecuteChanged(); SelectDestinationFolderCommand.RaiseCanExecuteChanged(); StartProcessingCommand.RaiseCanExecuteChanged(); diff --git a/CatalogLite/MainWindow.axaml b/CatalogLite/MainWindow.axaml index 48118bd..3067595 100644 --- a/CatalogLite/MainWindow.axaml +++ b/CatalogLite/MainWindow.axaml @@ -5,24 +5,11 @@ x:CompileBindings="False" Title="Catalog Lite" Width="740" - Height="380" + Height="560" MinWidth="640" - MinHeight="340"> - - - - - - - + MinHeight="500"> + + + - + + + + + + + + + + + + + + + Dispatcher.UIThread.Invoke(action); - _viewModel.LoadConfigurationRequested += OnLoadConfigurationRequested; _viewModel.SelectSourceFolderRequested += OnSelectSourceFolderRequested; _viewModel.SelectDestinationFolderRequested += OnSelectDestinationFolderRequested; _viewModel.ShowMessageRequested += OnShowMessageRequested; - } - - private async void OnLoadConfigurationRequested(object? sender, EventArgs e) - { - var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions - { - Title = "Carica configurazione XML", - FileTypeFilter = [new FilePickerFileType("XML") { Patterns = ["*.xml"] }] - }); - - if (files.Count > 0) - { - await _viewModel.LoadConfigurationFromFileAsync(files[0].Path.LocalPath); - } + _viewModel.LoadEmbeddedConfiguration(); } private async void OnSelectSourceFolderRequested(object? sender, EventArgs e) @@ -67,6 +55,16 @@ public partial class MainWindow : Window } } + private void OpenSourcePath_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + OpenInExplorer(_viewModel.SourcePath); + } + + private void OpenDestinationPath_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + OpenInExplorer(_viewModel.DestinationPath); + } + private async void OnShowMessageRequested(object? sender, LiteMessageEventArgs e) { await ShowMessageDialogAsync(e.Title, e.Message); @@ -110,4 +108,46 @@ public partial class MainWindow : Window closeButton.Click += (_, _) => dialog.Close(); await dialog.ShowDialog(this); } + + private static void OpenInExplorer(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var normalizedPath = path.Trim().Trim('"'); + + try + { + if (File.Exists(normalizedPath)) + { + Process.Start("explorer.exe", $"/select,\"{normalizedPath}\""); + return; + } + + if (Directory.Exists(normalizedPath)) + { + Process.Start(new ProcessStartInfo + { + FileName = normalizedPath, + UseShellExecute = true + }); + return; + } + + var containingDirectory = Path.GetDirectoryName(normalizedPath); + if (!string.IsNullOrWhiteSpace(containingDirectory) && Directory.Exists(containingDirectory)) + { + Process.Start(new ProcessStartInfo + { + FileName = containingDirectory, + UseShellExecute = true + }); + } + } + catch + { + } + } } \ No newline at end of file diff --git a/MaddoShared.Tests/DataModelCharacterizationTests.cs b/MaddoShared.Tests/DataModelCharacterizationTests.cs index ac09cb8..6e8c82c 100644 --- a/MaddoShared.Tests/DataModelCharacterizationTests.cs +++ b/MaddoShared.Tests/DataModelCharacterizationTests.cs @@ -283,9 +283,120 @@ public class DataModelCharacterizationTests output.LogFilePath.ShouldBe(@"C:\out\encoder_log_20260509_143045_04_APRILE_gara.txt"); } + [TestMethod] + public void FaceUploadPath_ValidatesExpectedRelativePathShape() + { + DataModel.IsValidFaceUploadPath("2026/05.MAGGIO/EMPOLI").ShouldBeTrue(); + DataModel.IsValidFaceUploadPath("2026/5.MAGGIO/EMPOLI").ShouldBeFalse(); + DataModel.IsValidFaceUploadPath("2026/00.MAGGIO/EMPOLI").ShouldBeFalse(); + DataModel.IsValidFaceUploadPath("2026/13.MAGGIO/EMPOLI").ShouldBeFalse(); + DataModel.IsValidFaceUploadPath("2026/05.MAGGIO").ShouldBeFalse(); + + DataModel.CombineRemoteUploadPath("/mnt/da1/foto/", "2026/05.MAGGIO/EMPOLI") + .ShouldBe("/mnt/da1/foto/2026/05.MAGGIO/EMPOLI"); + } + + [TestMethod] + public void FaceUploadCommand_IsEnabledOnlyForValidUploadPath() + { + var model = CreateModel(); + + model.UploadFaceEncoderOutputCommand.CanExecute(null).ShouldBeFalse(); + + model.FaceUploadPath = "2026/05.MAGGIO/EMPOLI"; + model.UploadFaceEncoderOutputCommand.CanExecute(null).ShouldBeTrue(); + + model.FaceUploadPath = "2026/5.MAGGIO/EMPOLI"; + model.UploadFaceEncoderOutputCommand.CanExecute(null).ShouldBeFalse(); + } + + [TestMethod] + public void FaceSshPreferences_AreStoredInUserPreferences() + { + using var tempDirectory = new TemporaryDirectory(); + var preferencesFile = Path.Combine(tempDirectory.Path, "userprefs.xml"); + var preferenceService = new PickerPreferenceService(new ImageCatalog.ParametriSetup(preferencesFile)); + + var model = CreateModel(pickerPreferenceService: preferenceService); + model.FaceSshUsername = "ssh-user"; + model.FaceSshPassword = "ssh-password"; + model.FaceSshAddress = "upload.example.org"; + model.FaceSshPort = "2222"; + model.FaceSshPathA = "/mnt/da1/foto/"; + model.FaceSshPathB = "/mnt/nas12/foto/"; + model.FaceUploadDryRun = true; + + var reloadedPreferenceService = new PickerPreferenceService(new ImageCatalog.ParametriSetup(preferencesFile)); + var reloaded = CreateModel(pickerPreferenceService: reloadedPreferenceService); + + reloaded.FaceSshUsername.ShouldBe("ssh-user"); + reloaded.FaceSshPassword.ShouldBe("ssh-password"); + reloaded.FaceSshAddress.ShouldBe("upload.example.org"); + reloaded.FaceSshPort.ShouldBe("2222"); + reloaded.FaceSshPathA.ShouldBe("/mnt/da1/foto/"); + reloaded.FaceSshPathB.ShouldBe("/mnt/nas12/foto/"); + reloaded.FaceUploadDryRun.ShouldBeTrue(); + } + + [TestMethod] + public void ResolveLatestFaceUploadSourceFile_UsesLatestPklForCurrentRace() + { + using var tempDirectory = new TemporaryDirectory(); + var outputFolder = Path.Combine(tempDirectory.Path, "out"); + var currentRaceFolder = Path.Combine(tempDirectory.Path, "04 APRILE gara"); + Directory.CreateDirectory(outputFolder); + Directory.CreateDirectory(currentRaceFolder); + + var olderCurrentRace = Path.Combine(outputFolder, "face_encodings_20260509_143045_04_APRILE_gara.pkl"); + var newerCurrentRace = Path.Combine(outputFolder, "face_encodings_20260509_153045_04_APRILE_gara.pkl"); + var otherRace = Path.Combine(outputFolder, "face_encodings_20260509_163045_05_MAGGIO_gara.pkl"); + + File.WriteAllText(olderCurrentRace, "old"); + File.WriteAllText(newerCurrentRace, "new"); + File.WriteAllText(otherRace, "other"); + + File.SetLastWriteTimeUtc(olderCurrentRace, new DateTime(2026, 5, 9, 14, 30, 45, DateTimeKind.Utc)); + File.SetLastWriteTimeUtc(newerCurrentRace, new DateTime(2026, 5, 9, 15, 30, 45, DateTimeKind.Utc)); + File.SetLastWriteTimeUtc(otherRace, new DateTime(2026, 5, 9, 16, 30, 45, DateTimeKind.Utc)); + + var selected = DataModel.ResolveLatestFaceUploadSourceFile(outputFolder, currentRaceFolder); + + selected.ShouldBe(newerCurrentRace); + } + + [TestMethod] + public async Task SettingsService_PersistsFaceUploadPathButNotSshPreferences() + { + using var tempDirectory = new TemporaryDirectory(); + var settingsFile = Path.Combine(tempDirectory.Path, "settings.xml"); + var userPreferencesFile = Path.Combine(tempDirectory.Path, "userprefs.xml"); + var preferenceService = new PickerPreferenceService(new ImageCatalog.ParametriSetup(userPreferencesFile)); + var settingsService = new SettingsService( + new ImageCatalog.ParametriSetup(Path.Combine(tempDirectory.Path, "unused.xml")), + Substitute.For>()); + var model = CreateModel(settingsService: settingsService, pickerPreferenceService: preferenceService); + + model.FaceUploadPath = "2026/05.MAGGIO/EMPOLI"; + model.FaceSshUsername = "ssh-user"; + + await settingsService.SaveSettingsAsync(settingsFile, model); + + var xml = File.ReadAllText(settingsFile); + xml.ShouldContain("AI_FaceUploadPath"); + xml.ShouldContain("2026/05.MAGGIO/EMPOLI"); + xml.ShouldNotContain("AI_FaceUploadDryRun"); + xml.ShouldNotContain("FaceAI.Ssh"); + xml.ShouldNotContain("ssh-user"); + + var loaded = CreateModel(settingsService: settingsService); + await settingsService.LoadSettingsAsync(settingsFile, loaded); + loaded.FaceUploadPath.ShouldBe("2026/05.MAGGIO/EMPOLI"); + } + private static DataModel CreateModel( ISettingsService? settingsService = null, - ITestService? testService = null) + ITestService? testService = null, + PickerPreferenceService? pickerPreferenceService = null) { var mapper = Substitute.For(); var picSettings = new PicSettings(); @@ -316,7 +427,8 @@ public class DataModelCharacterizationTests picSettings, mapper, Substitute.For>(), - versionProvider: null); + versionProvider: null, + pickerPreferenceService: pickerPreferenceService); } private static string CreateFaceEncoderExecutable(string rootPath, string variant) diff --git a/MaddoShared/ImageCreationStuff.cs b/MaddoShared/ImageCreationStuff.cs index 7d22457..9622b7d 100644 --- a/MaddoShared/ImageCreationStuff.cs +++ b/MaddoShared/ImageCreationStuff.cs @@ -71,8 +71,8 @@ namespace MaddoShared int threads = options.MaxThreads; // Load logo once as raw bytes (cross-platform). byte[] is safe to share across threads. - byte[]? logoBytes = null; - if (picSettings.LogoAggiungi && File.Exists(picSettings.LogoNomeFile)) + byte[]? logoBytes = picSettings.LogoData; + if (logoBytes is null && picSettings.LogoAggiungi && File.Exists(picSettings.LogoNomeFile)) { logoBytes = File.ReadAllBytes(picSettings.LogoNomeFile); } diff --git a/MaddoShared/PicSettings.cs b/MaddoShared/PicSettings.cs index 0623a7f..62acc9d 100644 --- a/MaddoShared/PicSettings.cs +++ b/MaddoShared/PicSettings.cs @@ -10,6 +10,7 @@ public class PicSettings public string DirectoryDestinazione { get; set; } public string TestoFirmaStart { get; set; } public string TestoFirmaStartV { get; set; } + public byte[]? LogoData { get; set; } public DateTime DataPartenza { get; set; } public string TestoOrario { get; set; } public int DimStandard { get; set; } diff --git a/imagecatalog/AvaloniaMainWindow.axaml b/imagecatalog/AvaloniaMainWindow.axaml index c01f37f..3b058a0 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml +++ b/imagecatalog/AvaloniaMainWindow.axaml @@ -88,7 +88,7 @@ - + diff --git a/imagecatalog/AvaloniaMainWindow.axaml.cs b/imagecatalog/AvaloniaMainWindow.axaml.cs index 6740d6f..20e5fb2 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml.cs +++ b/imagecatalog/AvaloniaMainWindow.axaml.cs @@ -273,10 +273,11 @@ public partial class AvaloniaMainWindow : Window var dialog = new Window { Title = title, - Width = 480, + Width = 960, + Height = 560, CanResize = false, WindowStartupLocation = WindowStartupLocation.CenterOwner, - SizeToContent = SizeToContent.Height + SizeToContent = SizeToContent.Manual }; dialog.Content = BuildMessageDialogContent(message, () => dialog.Close()); @@ -311,11 +312,17 @@ public partial class AvaloniaMainWindow : Window Spacing = 12 }; - layout.Children.Add(new TextBlock + layout.Children.Add(new ScrollViewer { - Text = message, - TextWrapping = Avalonia.Media.TextWrapping.Wrap, - MaxWidth = 420 + Height = 460, + HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + Content = new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + FontFamily = new Avalonia.Media.FontFamily("Cascadia Mono, Consolas, monospace") + } }); var closeButton = new Button diff --git a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml index dc1aed0..4564e7d 100644 --- a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml +++ b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml @@ -79,6 +79,19 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index 4ed641a..b5ea1d6 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -41,6 +41,7 @@ namespace ImageCatalog_2 public ICommand StartAiCommand { get; } public ICommand StartFaceEncoderCommand { get; } public ICommand StopFaceEncoderCommand { get; } + public ICommand UploadFaceEncoderOutputCommand { get; } public ICommand StartFaceMatcherCommand { get; } public ICommand StopFaceMatcherCommand { get; } @@ -50,6 +51,7 @@ namespace ImageCatalog_2 private readonly ImageCreationService _imageCreationService; private readonly IAiExtractionService _aiExtractionService; private readonly IImageProcessingCoordinator _imageProcessingCoordinator; + private readonly PickerPreferenceService? _pickerPreferenceService; private readonly ProcessingStateViewModel _processing; private readonly PathSettingsViewModel _paths; private readonly AiSettingsViewModel _ai; @@ -59,6 +61,7 @@ namespace ImageCatalog_2 private readonly IMapper _mapper; private readonly AsyncCommand _startFaceEncoderCommand; private readonly AsyncCommand _stopFaceEncoderCommand; + private readonly AsyncCommand _uploadFaceEncoderOutputCommand; private readonly AsyncCommand _startFaceMatcherCommand; private readonly AsyncCommand _stopFaceMatcherCommand; private readonly object _faceEncoderProcessLock = new(); @@ -75,12 +78,15 @@ namespace ImageCatalog_2 private Task? _faceMatcherLogWatcherTask; private bool _hasStartedFaceEncoderInSession; private bool _hasStartedFaceMatcherInSession; + private bool _isLoadingFaceSshUserPreferences; + private string _lastFaceEncoderOutputFilePath = string.Empty; private int _numberAiGpuRefreshVersion; private volatile bool _numberAiGpuValidationPending; private sealed record ParsedFaceMatcherRow(string PhotoId, double? Score, string RawRow, string DebugSummary); private const string AiCsvOverwriteDialogTitle = "File CSV gia esistente"; + private static readonly Regex FaceUploadPathRegex = new(@"^\d{4}/(?:0[1-9]|1[0-2])\.[^/\\]+/[^/\\]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant); // ComboBox collections public List AvailableFonts { get; } @@ -90,7 +96,7 @@ namespace ImageCatalog_2 [CLSCompliant(false)] public DataModel(ITestService testService, ISettingsService settingsService, ImageCreationService imageCreationService, IAiExtractionService aiExtractionService, IImageProcessingCoordinator imageProcessingCoordinator, PicSettings picSettings, - IMapper mapper, ILogger logger, MaddoShared.IVersionProvider? versionProvider = null) + IMapper mapper, ILogger logger, MaddoShared.IVersionProvider? versionProvider = null, PickerPreferenceService? pickerPreferenceService = null) { _service = testService; _logger = logger; @@ -98,6 +104,7 @@ namespace ImageCatalog_2 _imageCreationService = imageCreationService; _aiExtractionService = aiExtractionService; _imageProcessingCoordinator = imageProcessingCoordinator; + _pickerPreferenceService = pickerPreferenceService; _processing = new ProcessingStateViewModel(); _processing.PropertyChanged += OnProcessingPropertyChanged; _paths = new PathSettingsViewModel(); @@ -122,10 +129,12 @@ namespace ImageCatalog_2 StartAiCommand = new AsyncCommand(StartAiAsync); _startFaceEncoderCommand = new AsyncCommand(RunFaceEncoderAsync, CanRunFaceEncoder); _stopFaceEncoderCommand = new AsyncCommand(() => StopFaceEncoderAsync("Arresto richiesto dall'utente."), CanStopFaceEncoder); + _uploadFaceEncoderOutputCommand = new AsyncCommand(UploadFaceEncoderOutputAsync, CanUploadFaceEncoderOutput); _startFaceMatcherCommand = new AsyncCommand(RunFaceMatcherAsync, CanRunFaceMatcher); _stopFaceMatcherCommand = new AsyncCommand(() => StopFaceMatcherAsync("Arresto richiesto dall'utente."), CanStopFaceMatcher); StartFaceEncoderCommand = _startFaceEncoderCommand; StopFaceEncoderCommand = _stopFaceEncoderCommand; + UploadFaceEncoderOutputCommand = _uploadFaceEncoderOutputCommand; StartFaceMatcherCommand = _startFaceMatcherCommand; StopFaceMatcherCommand = _stopFaceMatcherCommand; @@ -139,6 +148,7 @@ namespace ImageCatalog_2 // Load available fonts AvailableFonts = LoadAvailableFonts(); + LoadFaceSshUserPreferences(); QueueRefreshNumberAiGpuCapabilities(); RefreshFaceExecutableCapabilities(); } @@ -330,6 +340,67 @@ namespace ImageCatalog_2 set => _ai.FaceOutputFolderPath = value; } + public string FaceUploadPath + { + get => _ai.FaceUploadPath; + set + { + _ai.FaceUploadPath = value?.Trim() ?? string.Empty; + NotifyPropertyChanged(nameof(IsFaceUploadPathValid)); + UpdateFaceUploadCommandStates(); + } + } + + public bool FaceUploadDryRun + { + get => _ai.FaceUploadDryRun; + set => SetFaceSshPreferenceBoolValue(PickerPreferenceKeys.FaceUploadDryRun, normalizedValue => _ai.FaceUploadDryRun = normalizedValue, value); + } + + public bool IsFaceUploadPathValid => IsValidFaceUploadPath(FaceUploadPath); + + public string FaceSshUsername + { + get => _ai.FaceSshUsername; + set => SetFaceSshPreferenceValue(PickerPreferenceKeys.FaceSshUsername, normalizedValue => _ai.FaceSshUsername = normalizedValue, value?.Trim() ?? string.Empty); + } + + public string FaceSshPassword + { + get => _ai.FaceSshPassword; + set => SetFaceSshPreferenceValue(PickerPreferenceKeys.FaceSshPassword, normalizedValue => _ai.FaceSshPassword = normalizedValue, value ?? string.Empty); + } + + public string FaceSshAddress + { + get => _ai.FaceSshAddress; + set => SetFaceSshPreferenceValue(PickerPreferenceKeys.FaceSshAddress, normalizedValue => _ai.FaceSshAddress = normalizedValue, value?.Trim() ?? string.Empty); + } + + public string FaceSshPort + { + get => _ai.FaceSshPort; + set => SetFaceSshPreferenceValue(PickerPreferenceKeys.FaceSshPort, normalizedValue => _ai.FaceSshPort = normalizedValue, value?.Trim() ?? string.Empty); + } + + public string FaceSshPathA + { + get => _ai.FaceSshPathA; + set => SetFaceSshPreferenceValue(PickerPreferenceKeys.FaceSshPathA, normalizedValue => _ai.FaceSshPathA = normalizedValue, value?.Trim() ?? string.Empty); + } + + public string FaceSshPathB + { + get => _ai.FaceSshPathB; + set => SetFaceSshPreferenceValue(PickerPreferenceKeys.FaceSshPathB, normalizedValue => _ai.FaceSshPathB = normalizedValue, value?.Trim() ?? string.Empty); + } + + public bool IsFaceUploadRunning + { + get => _ai.IsFaceUploadRunning; + private set => _ai.IsFaceUploadRunning = value; + } + public bool FaceRecursive { get => _ai.FaceRecursive; @@ -668,7 +739,13 @@ namespace ImageCatalog_2 } NotifyPropertyChanged(e.PropertyName); + if (string.Equals(e.PropertyName, nameof(AiSettingsViewModel.FaceUploadPath), StringComparison.Ordinal)) + { + NotifyPropertyChanged(nameof(IsFaceUploadPathValid)); + } + UpdateFaceEncoderCommandStates(); + UpdateFaceUploadCommandStates(); UpdateFaceMatcherCommandStates(); } @@ -1572,6 +1649,11 @@ namespace ImageCatalog_2 _stopFaceEncoderCommand?.RaiseCanExecuteChanged(); } + private void UpdateFaceUploadCommandStates() + { + _uploadFaceEncoderOutputCommand?.RaiseCanExecuteChanged(); + } + private void UpdateFaceMatcherCommandStates() { _startFaceMatcherCommand?.RaiseCanExecuteChanged(); @@ -1631,6 +1713,7 @@ namespace ImageCatalog_2 var parallelism = NormalizeFaceParallelism(FaceParallelism); var minSize = NormalizeFaceMinSize(FaceMinSize); var outputFiles = BuildFaceEncoderOutputPaths(outputFolderPath, imagesFolder, DateTime.Now); + _lastFaceEncoderOutputFilePath = outputFiles.OutputFilePath; FaceExecutablePath = executableRootPath; FaceOutputFolderPath = outputFolderPath; @@ -1748,6 +1831,66 @@ namespace ImageCatalog_2 return !IsFaceMatcherRunning; } + private bool CanUploadFaceEncoderOutput() + { + return IsFaceUploadPathValid && !IsFaceUploadRunning; + } + + private async Task UploadFaceEncoderOutputAsync() + { + if (!IsValidFaceUploadPath(FaceUploadPath)) + { + FaceStatusMessage = "Percorso upload non valido."; + return; + } + + if (!TryBuildFaceUploadRequest(out var request, out var validationMessage)) + { + FaceStatusMessage = validationMessage; + await ShowErrorMessageAsync("Upload Face AI", validationMessage).ConfigureAwait(false); + return; + } + + IsFaceUploadRunning = true; + UpdateFaceUploadCommandStates(); + FaceStatusMessage = "Upload face encoder in corso..."; + + try + { + if (FaceUploadDryRun) + { + var preview = BuildFaceUploadDryRunPreview(request); + await ShowMessageAsync("Upload Face AI (dry run)", preview).ConfigureAwait(false); + await InvokeOnUiThreadAsync(() => FaceStatusMessage = "Dry run: comando mostrato nel popup.").ConfigureAwait(false); + return; + } + + var result = await RunFaceUploadPowerShellAsync(request).ConfigureAwait(false); + await InvokeOnUiThreadAsync(() => + { + FaceCommandOutput = string.IsNullOrWhiteSpace(FaceCommandOutput) + ? result + : $"{FaceCommandOutput.TrimEnd()}\n\n{result}"; + FaceStatusMessage = "Upload completato."; + }).ConfigureAwait(false); + } + catch (Exception ex) + { + var message = ex.GetBaseException().Message; + _logger.LogError(ex, "Face encoder upload failed."); + await InvokeOnUiThreadAsync(() => FaceStatusMessage = "Errore durante upload face encoder.").ConfigureAwait(false); + await ShowErrorMessageAsync("Upload Face AI", message).ConfigureAwait(false); + } + finally + { + await InvokeOnUiThreadAsync(() => + { + IsFaceUploadRunning = false; + UpdateFaceUploadCommandStates(); + }).ConfigureAwait(false); + } + } + private bool CanStopFaceMatcher() { return IsFaceMatcherRunning; @@ -2361,7 +2504,7 @@ namespace ImageCatalog_2 && File.Exists(configuration.RecognitionWeights); } - private Task ShowErrorMessageAsync(string title, string message) + private Task ShowMessageAsync(string title, string message) { return InvokeOnUiThreadAsync(() => { @@ -2369,6 +2512,11 @@ namespace ImageCatalog_2 }); } + private Task ShowErrorMessageAsync(string title, string message) + { + return ShowMessageAsync(title, message); + } + internal async Task ConfirmAiCsvOverwriteIfNeededAsync() { var csvOutputPath = NormalizeFilePathArgument(CsvOutputPath); @@ -2897,6 +3045,253 @@ namespace ImageCatalog_2 Path.Combine(outputFolderPath, $"encoder_log_{timestampToken}_{safeRaceName}.txt")); } + internal static bool IsValidFaceUploadPath(string value) + { + return FaceUploadPathRegex.IsMatch((value ?? string.Empty).Trim()); + } + + internal static string CombineRemoteUploadPath(string basePath, string relativePath) + { + var normalizedBasePath = (basePath ?? string.Empty).Trim().Replace('\\', '/').TrimEnd('/'); + var normalizedRelativePath = (relativePath ?? string.Empty).Trim().Replace('\\', '/').Trim('/'); + + return string.IsNullOrWhiteSpace(normalizedBasePath) + ? normalizedRelativePath + : string.IsNullOrWhiteSpace(normalizedRelativePath) + ? normalizedBasePath + : $"{normalizedBasePath}/{normalizedRelativePath}"; + } + + private void LoadFaceSshUserPreferences() + { + if (_pickerPreferenceService is null) + { + return; + } + + _isLoadingFaceSshUserPreferences = true; + try + { + _ai.FaceSshUsername = _pickerPreferenceService.GetRememberedValue(PickerPreferenceKeys.FaceSshUsername) ?? string.Empty; + _ai.FaceSshPassword = _pickerPreferenceService.GetRememberedRawValue(PickerPreferenceKeys.FaceSshPassword) ?? string.Empty; + _ai.FaceSshAddress = _pickerPreferenceService.GetRememberedValue(PickerPreferenceKeys.FaceSshAddress) ?? string.Empty; + _ai.FaceSshPort = _pickerPreferenceService.GetRememberedValue(PickerPreferenceKeys.FaceSshPort) ?? "22"; + _ai.FaceSshPathA = _pickerPreferenceService.GetRememberedValue(PickerPreferenceKeys.FaceSshPathA) ?? string.Empty; + _ai.FaceSshPathB = _pickerPreferenceService.GetRememberedValue(PickerPreferenceKeys.FaceSshPathB) ?? string.Empty; + _ai.FaceUploadDryRun = TryGetRememberedBoolPreference(PickerPreferenceKeys.FaceUploadDryRun); + } + finally + { + _isLoadingFaceSshUserPreferences = false; + } + } + + private void SetFaceSshPreferenceValue(string preferenceKey, Action setValue, string value) + { + setValue(value); + if (_isLoadingFaceSshUserPreferences) + { + return; + } + + _pickerPreferenceService?.RememberRawValue(preferenceKey, value); + } + + private void SetFaceSshPreferenceBoolValue(string preferenceKey, Action setValue, bool value) + { + setValue(value); + if (_isLoadingFaceSshUserPreferences) + { + return; + } + + _pickerPreferenceService?.RememberRawValue(preferenceKey, value ? bool.TrueString : bool.FalseString); + } + + private bool TryGetRememberedBoolPreference(string preferenceKey) + { + var rawValue = _pickerPreferenceService?.GetRememberedRawValue(preferenceKey); + return bool.TryParse(rawValue, out var parsed) && parsed; + } + + private bool TryBuildFaceUploadRequest(out FaceUploadRequest request, out string validationMessage) + { + request = null!; + validationMessage = string.Empty; + + var sourceFilePath = ResolveFaceEncoderUploadSourceFile(); + if (string.IsNullOrWhiteSpace(sourceFilePath) || !File.Exists(sourceFilePath)) + { + validationMessage = "File .pkl face encoder non trovato nella cartella output."; + return false; + } + + var username = FaceSshUsername.Trim(); + var password = FaceSshPassword; + var serverAddress = FaceSshAddress.Trim(); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(serverAddress)) + { + validationMessage = "Inserisci username e indirizzo SSH."; + return false; + } + + if (!int.TryParse(FaceSshPort.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var port) || port is < 1 or > 65535) + { + validationMessage = "Porta SSH non valida."; + return false; + } + + var remoteBasePaths = new[] { FaceSshPathA, FaceSshPathB } + .Select(path => path.Trim()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .ToArray(); + if (remoteBasePaths.Length != 2) + { + validationMessage = "Inserisci path SSH A e path SSH B."; + return false; + } + + var relativeUploadPath = FaceUploadPath.Trim(); + var remoteDirectories = remoteBasePaths + .Select(path => CombineRemoteUploadPath(path, relativeUploadPath)) + .ToArray(); + + request = new FaceUploadRequest(sourceFilePath, username, password, serverAddress, port, remoteDirectories); + return true; + } + + private string? ResolveFaceEncoderUploadSourceFile() + { + var outputFolderPath = NormalizeDirectoryPathArgument(FaceOutputFolderPath); + if (!Directory.Exists(outputFolderPath)) + { + return null; + } + + return ResolveLatestFaceUploadSourceFile(outputFolderPath, NormalizeDirectoryPathArgument(DestinationPath)); + } + + internal static string? ResolveLatestFaceUploadSourceFile(string outputFolderPath, string imagesFolderPath) + { + var normalizedOutputFolderPath = NormalizeDirectoryPathArgument(outputFolderPath); + if (!Directory.Exists(normalizedOutputFolderPath)) + { + return null; + } + + var safeRaceName = BuildSafeFaceEncoderRaceName(NormalizeDirectoryPathArgument(imagesFolderPath)); + var racePattern = $"face_encodings_*_{safeRaceName}.pkl"; + + return new DirectoryInfo(normalizedOutputFolderPath) + .EnumerateFiles(racePattern, SearchOption.TopDirectoryOnly) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ThenByDescending(file => file.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault() + ?.FullName; + } + + private async Task RunFaceUploadPowerShellAsync(FaceUploadRequest request) + { + var commandLine = BuildFaceUploadPowerShellCommand(request); + + var processStartInfo = CreateFaceUploadProcessStartInfo(commandLine); + + using var process = new Process { StartInfo = processStartInfo }; + + if (!process.Start()) + { + throw new InvalidOperationException("Avvio PowerShell per upload fallito."); + } + + await process.WaitForExitAsync().ConfigureAwait(false); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException($"Upload fallito (exit code {process.ExitCode}). Verifica autenticazione SSH o inserisci la password nel terminale se richiesta."); + } + + var summary = new StringBuilder(); + summary.AppendLine("Upload Face AI completato."); + summary.AppendLine($"Command: {commandLine}"); + summary.AppendLine($"File: {request.LocalPath}"); + foreach (var remoteDirectory in request.RemoteDirectories) + { + summary.AppendLine($"Destinazione: {remoteDirectory}"); + } + + return summary.ToString(); + } + + private static ProcessStartInfo CreateFaceUploadProcessStartInfo(string commandLine) + { + var processStartInfo = new ProcessStartInfo + { + FileName = ResolvePowerShellExecutable(), + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + RedirectStandardInput = false, + CreateNoWindow = false + }; + + processStartInfo.ArgumentList.Add("-NoProfile"); + processStartInfo.ArgumentList.Add("-ExecutionPolicy"); + processStartInfo.ArgumentList.Add("Bypass"); + processStartInfo.ArgumentList.Add("-Command"); + processStartInfo.ArgumentList.Add(commandLine); + + return processStartInfo; + } + + private static string BuildFaceUploadDryRunPreview(FaceUploadRequest request) + { + return BuildFaceUploadPowerShellCommand(request); + } + + private static string BuildFaceUploadPowerShellCommand(FaceUploadRequest request) + { + var fileName = Path.GetFileName(request.LocalPath); + var copyCommands = request.RemoteDirectories + .Select(remoteDirectory => BuildFaceUploadCopyCommand(request, CombineRemoteUploadPath(remoteDirectory, fileName))) + .ToArray(); + + return string.Join("; ", copyCommands); + } + + private static string ResolvePowerShellExecutable() + { + return OperatingSystem.IsWindows() ? "powershell.exe" : "pwsh"; + } + + private static string BuildFaceUploadCopyCommand(FaceUploadRequest request, string remoteFilePath) + { + return BuildScpCommand(request, remoteFilePath); + } + + private static string BuildScpCommand(FaceUploadRequest request, string remoteFilePath) + { + var remoteTarget = $"{request.UserName}@{request.ServerAddress}:{QuoteScpRemotePath(remoteFilePath)}"; + return $"scp -P {request.Port.ToString(CultureInfo.InvariantCulture)} {QuotePowerShellString(request.LocalPath)} {QuotePowerShellString(remoteTarget)}"; + } + + private static string QuotePowerShellString(string value) + { + return $"'{(value ?? string.Empty).Replace("'", "''", StringComparison.Ordinal)}'"; + } + + private static string QuoteScpRemotePath(string value) + { + return "'" + (value ?? string.Empty).Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + } + + private sealed record FaceUploadRequest( + string LocalPath, + string UserName, + string Password, + string ServerAddress, + int Port, + IReadOnlyList RemoteDirectories); + internal static string BuildSafeFaceEncoderRaceName(string imagesFolderPath) { var raceName = new DirectoryInfo(imagesFolderPath).Name; diff --git a/imagecatalog/Models/SettingsDto.cs b/imagecatalog/Models/SettingsDto.cs index 41c7dd5..91f81cd 100644 --- a/imagecatalog/Models/SettingsDto.cs +++ b/imagecatalog/Models/SettingsDto.cs @@ -289,6 +289,10 @@ namespace ImageCatalog_2.Models [XmlElement("AI_FaceOutputFolderPath")] public string FaceOutputFolderPath { get; set; } = string.Empty; + [JsonPropertyName("FaceUploadPath")] + [XmlElement("AI_FaceUploadPath")] + public string FaceUploadPath { get; set; } = string.Empty; + [JsonPropertyName("FaceRecursive")] [XmlElement("AI_FaceRecursive")] public bool FaceRecursive { get; set; } diff --git a/imagecatalog/Program.cs b/imagecatalog/Program.cs index aa02e40..997a47c 100644 --- a/imagecatalog/Program.cs +++ b/imagecatalog/Program.cs @@ -136,8 +136,9 @@ static class Program var mapper = sp.GetRequiredService(); var logger = sp.GetRequiredService>(); var versionProvider = sp.GetService(); + var pickerPreferenceService = sp.GetRequiredService(); - return new DataModel(testService, settingsService, imageCreation, aiExtractionService, imageProcessingCoordinator, picSettings, mapper, logger, versionProvider); + return new DataModel(testService, settingsService, imageCreation, aiExtractionService, imageProcessingCoordinator, picSettings, mapper, logger, versionProvider, pickerPreferenceService); }); services.AddTransient(); diff --git a/imagecatalog/Services/PickerPreferenceService.cs b/imagecatalog/Services/PickerPreferenceService.cs index 49bdaea..785ee4f 100644 --- a/imagecatalog/Services/PickerPreferenceService.cs +++ b/imagecatalog/Services/PickerPreferenceService.cs @@ -22,6 +22,13 @@ public static class PickerPreferenceKeys public const string FaceMatcherEncodings = "Picker.FaceMatcherEncodings.LastPath"; public const string FaceMatcherOutput = "Picker.FaceMatcherOutput.LastPath"; public const string FaceMatcherLog = "Picker.FaceMatcherLog.LastPath"; + public const string FaceSshUsername = "FaceAI.Ssh.Username"; + public const string FaceSshPassword = "FaceAI.Ssh.Password"; + public const string FaceSshAddress = "FaceAI.Ssh.Address"; + public const string FaceSshPort = "FaceAI.Ssh.Port"; + public const string FaceSshPathA = "FaceAI.Ssh.PathA"; + public const string FaceSshPathB = "FaceAI.Ssh.PathB"; + public const string FaceUploadDryRun = "FaceAI.Upload.DryRun"; } public sealed class PickerPreferenceService @@ -71,6 +78,13 @@ public sealed class PickerPreferenceService : value.Trim().Trim('"'); } + public string? GetRememberedRawValue(string preferenceKey) + { + return _userPreferences.ParametroExists(preferenceKey) + ? _userPreferences.LeggiParametroString(preferenceKey) + : null; + } + public void RememberValue(string preferenceKey, string? value) { if (string.IsNullOrWhiteSpace(value)) @@ -82,6 +96,12 @@ public sealed class PickerPreferenceService _userPreferences.SalvaParametriSetup(); } + public void RememberRawValue(string preferenceKey, string? value) + { + _userPreferences.AggiornaParametro(preferenceKey, value ?? string.Empty); + _userPreferences.SalvaParametriSetup(); + } + public void ForgetValue(string preferenceKey) { if (_userPreferences.RimuoviParametro(preferenceKey)) diff --git a/imagecatalog/ViewModels/AiSettingsViewModel.cs b/imagecatalog/ViewModels/AiSettingsViewModel.cs index e38213c..118d577 100644 --- a/imagecatalog/ViewModels/AiSettingsViewModel.cs +++ b/imagecatalog/ViewModels/AiSettingsViewModel.cs @@ -115,6 +115,105 @@ public class AiSettingsViewModel : ViewModelBase } } + private string _faceUploadPath = string.Empty; + public string FaceUploadPath + { + get => _faceUploadPath; + set + { + _faceUploadPath = value; + NotifyPropertyChanged(); + } + } + + private bool _faceUploadDryRun; + public bool FaceUploadDryRun + { + get => _faceUploadDryRun; + set + { + _faceUploadDryRun = value; + NotifyPropertyChanged(); + } + } + + private string _faceSshUsername = string.Empty; + public string FaceSshUsername + { + get => _faceSshUsername; + set + { + _faceSshUsername = value; + NotifyPropertyChanged(); + } + } + + private string _faceSshPassword = string.Empty; + public string FaceSshPassword + { + get => _faceSshPassword; + set + { + _faceSshPassword = value; + NotifyPropertyChanged(); + } + } + + private string _faceSshAddress = string.Empty; + public string FaceSshAddress + { + get => _faceSshAddress; + set + { + _faceSshAddress = value; + NotifyPropertyChanged(); + } + } + + private string _faceSshPort = "22"; + public string FaceSshPort + { + get => _faceSshPort; + set + { + _faceSshPort = value; + NotifyPropertyChanged(); + } + } + + private string _faceSshPathA = string.Empty; + public string FaceSshPathA + { + get => _faceSshPathA; + set + { + _faceSshPathA = value; + NotifyPropertyChanged(); + } + } + + private string _faceSshPathB = string.Empty; + public string FaceSshPathB + { + get => _faceSshPathB; + set + { + _faceSshPathB = value; + NotifyPropertyChanged(); + } + } + + private bool _isFaceUploadRunning; + public bool IsFaceUploadRunning + { + get => _isFaceUploadRunning; + set + { + _isFaceUploadRunning = value; + NotifyPropertyChanged(); + } + } + private bool _faceRecursive; public bool FaceRecursive {