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
{