feat: Enhance Face AI upload functionality and UI
Some checks failed
Build Windows Avalonia / build (push) Failing after 1m48s
Release Windows Avalonia / build (push) Failing after 1m41s
Release Windows Avalonia / release (push) Has been skipped

- Updated MainWindow.axaml to increase height and add new UI elements for SSH upload configuration.
- Implemented commands for opening source and destination paths in file explorer.
- Added FaceUploadPath and SSH configuration properties to DataModel and AiSettingsViewModel.
- Introduced validation for FaceUploadPath format and commands for uploading face encoder output.
- Enhanced PickerPreferenceService to manage SSH credentials and upload preferences.
- Updated settings persistence to include FaceUploadPath and SSH preferences.
- Added tests for FaceUploadPath validation and upload command enabling logic.
This commit is contained in:
Maddo 2026-06-06 11:54:21 +02:00
commit e9142df97c
22 changed files with 1477 additions and 84 deletions

View file

@ -88,7 +88,7 @@
<views:FaceAiTabView />
</TabItem>
<TabItem>
<TabItem IsVisible="False">
<TabItem.Header>
<StackPanel Orientation="Horizontal">
<iconPacks:PackIconMaterial Kind="CloudUploadOutline" Width="14" Height="14" Margin="0,0,4,0" Foreground="{DynamicResource ForegroundBrush}" />

View file

@ -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

View file

@ -79,6 +79,19 @@
<TextBlock VerticalAlignment="Center" Text="{Binding FaceStatusMessage}" TextWrapping="Wrap" />
</StackPanel>
<Grid ColumnDefinitions="Auto,320,Auto,*" ColumnSpacing="6" Margin="0,2,0,0">
<TextBlock Grid.Column="0" Text="Upload:" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding FaceUploadPath, Mode=TwoWay}"
Watermark="2026/05.MAGGIO/EMPOLI" />
<Button Grid.Column="2" Command="{Binding UploadFaceEncoderOutputCommand}" Width="120">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6">
<iconPacks:PackIconMaterial Kind="CloudUploadOutline" Width="16" Height="16" Foreground="#1565C0" />
<TextBlock Text="Upload" />
</StackPanel>
</Button>
</Grid>
<TextBlock Text="Output comando" FontWeight="Bold" Margin="0,6,0,0" />
<TextBox Name="FaceOutputTextBox"
Text="{Binding FaceCommandOutput}"
@ -110,6 +123,31 @@
PreferenceKey="Picker.FaceExecutableFolder.LastPath"
PickerTitle="Seleziona la cartella Face Recognition Windows"
PickerMode="Folder" />
<TextBlock Text="SSH upload" FontWeight="Bold" Margin="0,8,0,0" />
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto" RowSpacing="6" ColumnSpacing="8">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Username:" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FaceSshUsername, Mode=TwoWay}" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Password:" VerticalAlignment="Center" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding FaceSshPassword, Mode=TwoWay}" PasswordChar="*" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Server:" VerticalAlignment="Center" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding FaceSshAddress, Mode=TwoWay}" Watermark="example.org" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Porta:" VerticalAlignment="Center" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding FaceSshPort, Mode=TwoWay}" Watermark="22" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Path A:" VerticalAlignment="Center" />
<TextBox Grid.Row="4" Grid.Column="1" Text="{Binding FaceSshPathA, Mode=TwoWay}" Watermark="/mnt/da1/foto/" />
<TextBlock Grid.Row="5" Grid.Column="0" Text="Path B:" VerticalAlignment="Center" />
<TextBox Grid.Row="5" Grid.Column="1" Text="{Binding FaceSshPathB, Mode=TwoWay}" Watermark="/mnt/nas12/foto/" />
<CheckBox Grid.Row="6" Grid.ColumnSpan="2"
Content="Dry run upload Face AI"
IsChecked="{Binding FaceUploadDryRun, Mode=TwoWay}" />
</Grid>
</StackPanel>
</ScrollViewer>
</TabItem>

View file

@ -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<string> 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<DataModel> logger, MaddoShared.IVersionProvider? versionProvider = null)
IMapper mapper, ILogger<DataModel> 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<bool> 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<string> setValue, string value)
{
setValue(value);
if (_isLoadingFaceSshUserPreferences)
{
return;
}
_pickerPreferenceService?.RememberRawValue(preferenceKey, value);
}
private void SetFaceSshPreferenceBoolValue(string preferenceKey, Action<bool> 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<string> 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<string> RemoteDirectories);
internal static string BuildSafeFaceEncoderRaceName(string imagesFolderPath)
{
var raceName = new DirectoryInfo(imagesFolderPath).Name;

View file

@ -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; }

View file

@ -136,8 +136,9 @@ static class Program
var mapper = sp.GetRequiredService<IMapper>();
var logger = sp.GetRequiredService<ILogger<DataModel>>();
var versionProvider = sp.GetService<MaddoShared.IVersionProvider>();
var pickerPreferenceService = sp.GetRequiredService<PickerPreferenceService>();
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<IAiExtractionService, AiExtractionService>();

View file

@ -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))

View file

@ -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
{