diff --git a/MaddoShared.Tests/DataModelCharacterizationTests.cs b/MaddoShared.Tests/DataModelCharacterizationTests.cs index 0a94ae9..ac09cb8 100644 --- a/MaddoShared.Tests/DataModelCharacterizationTests.cs +++ b/MaddoShared.Tests/DataModelCharacterizationTests.cs @@ -96,6 +96,45 @@ public class DataModelCharacterizationTests model.DestinationPath.ShouldBe($"C:{System.IO.Path.DirectorySeparatorChar}output{System.IO.Path.DirectorySeparatorChar}"); } + [TestMethod] + public void DestinationPathChange_UpdatesAiCsvFileNameToMatchFinalFolderSegment() + { + var model = CreateModel(); + model.CsvOutputPath = @"K:\various\catalogtest\aioutput\test2.csv"; + + model.DestinationPath = @"K:\various\catalogtest\Dest\03.KM_8_A\"; + + model.CsvOutputPath.ShouldBe(@"K:\various\catalogtest\aioutput\03.KM_8_A.csv"); + } + + [TestMethod] + public async Task ConfirmAiCsvOverwriteIfNeededAsync_CanCancelWhenCsvAlreadyExists() + { + using var tempDirectory = new TemporaryDirectory(); + var csvPath = Path.Combine(tempDirectory.Path, "existing.csv"); + File.WriteAllText(csvPath, "existing"); + + var model = CreateModel(); + model.CsvOutputPath = csvPath; + + string? requestedTitle = null; + string? requestedMessage = null; + model.ConfirmAiCsvOverwriteAsync = (title, message) => + { + requestedTitle = title; + requestedMessage = message; + return Task.FromResult(false); + }; + + var shouldContinue = await model.ConfirmAiCsvOverwriteIfNeededAsync(); + + shouldContinue.ShouldBeFalse(); + requestedTitle.ShouldBe("File CSV gia esistente"); + requestedMessage.ShouldNotBeNull(); + requestedMessage.ShouldContain("Vuoi sovrascriverlo?"); + requestedMessage.ShouldContain(csvPath); + } + [TestMethod] public void AiChildChange_RaisesDataModelPropertyChanged() { diff --git a/imagecatalog/AvaloniaMainWindow.axaml.cs b/imagecatalog/AvaloniaMainWindow.axaml.cs index d996708..6740d6f 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml.cs +++ b/imagecatalog/AvaloniaMainWindow.axaml.cs @@ -41,6 +41,7 @@ public partial class AvaloniaMainWindow : Window // Let DataModel marshal callbacks onto Avalonia UI thread. _model.UiInvoker = action => Dispatcher.UIThread.Invoke(action); + _model.ConfirmAiCsvOverwriteAsync = ShowConfirmationDialogAsync; _model.SelectSourceFolderRequested += async (_, _) => { @@ -283,6 +284,25 @@ public partial class AvaloniaMainWindow : Window await dialog.ShowDialog(this); } + private async Task ShowConfirmationDialogAsync(string title, string message) + { + var dialog = new Window + { + Title = title, + Width = 520, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + SizeToContent = SizeToContent.Height + }; + + dialog.Content = BuildConfirmationDialogContent( + message, + () => dialog.Close(true), + () => dialog.Close(false)); + + return await dialog.ShowDialog(this); + } + private static Control BuildMessageDialogContent(string message, Action closeDialog) { var layout = new StackPanel @@ -310,4 +330,46 @@ public partial class AvaloniaMainWindow : Window layout.Children.Add(closeButton); return layout; } + + private static Control BuildConfirmationDialogContent(string message, Action confirmDialog, Action cancelDialog) + { + var layout = new StackPanel + { + Margin = new Thickness(16), + Spacing = 12 + }; + + layout.Children.Add(new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + MaxWidth = 460 + }); + + var buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8 + }; + + var cancelButton = new Button + { + Content = "Annulla", + MinWidth = 96 + }; + cancelButton.Click += (_, _) => cancelDialog(); + + var confirmButton = new Button + { + Content = "Sovrascrivi", + MinWidth = 96 + }; + confirmButton.Click += (_, _) => confirmDialog(); + + buttons.Children.Add(cancelButton); + buttons.Children.Add(confirmButton); + layout.Children.Add(buttons); + return layout; + } } diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index c89cb2c..d4a3dc4 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -22,6 +22,7 @@ using System.Text.RegularExpressions; using AutoMapper; using MaddoShared; using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; namespace ImageCatalog_2 { @@ -82,6 +83,8 @@ namespace ImageCatalog_2 private sealed record ParsedFaceMatcherRow(string PhotoId, double? Score, string RawRow, string DebugSummary); + private const string AiCsvOverwriteDialogTitle = "File CSV gia esistente"; + // ComboBox collections public List AvailableFonts { get; } public List VerticalPositions { get; } = new() { "Alto", "Centro", "Basso" }; @@ -145,6 +148,12 @@ namespace ImageCatalog_2 private async Task StartAiAsync() { + if (!await ConfirmAiCsvOverwriteIfNeededAsync().ConfigureAwait(false)) + { + await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = "OCR annullato.").ConfigureAwait(false); + return; + } + MainToken = new CancellationTokenSource(); try { @@ -267,6 +276,8 @@ namespace ImageCatalog_2 set => _ai.CsvOutputPath = value; } + public Func>? ConfirmAiCsvOverwriteAsync { get; set; } + public bool UseNumberAiGpu { get => _ai.UseNumberAiGpu; @@ -641,6 +652,11 @@ namespace ImageCatalog_2 return; } + if (string.Equals(e.PropertyName, nameof(PathSettingsViewModel.DestinationPath), StringComparison.Ordinal)) + { + UpdateAiCsvOutputPathForDestination(); + } + NotifyPropertyChanged(e.PropertyName); } @@ -2403,6 +2419,103 @@ namespace ImageCatalog_2 }); } + internal async Task ConfirmAiCsvOverwriteIfNeededAsync() + { + var csvOutputPath = NormalizeFilePathArgument(CsvOutputPath); + if (string.IsNullOrWhiteSpace(csvOutputPath) || !File.Exists(csvOutputPath)) + { + return true; + } + + var confirmOverwrite = ConfirmAiCsvOverwriteAsync; + if (confirmOverwrite is null) + { + return true; + } + + var message = $"Il file CSV esiste gia:\n{csvOutputPath}\n\nVuoi sovrascriverlo? Se scegli Annulla l'operazione OCR non verra avviata."; + return await confirmOverwrite(AiCsvOverwriteDialogTitle, message).ConfigureAwait(false); + } + + internal void UpdateAiCsvOutputPathForDestination() + { + var updatedPath = BuildAiCsvOutputPathForDestination(CsvOutputPath, DestinationPath); + if (string.Equals(updatedPath, CsvOutputPath, StringComparison.Ordinal)) + { + return; + } + + CsvOutputPath = updatedPath; + } + + internal static string BuildAiCsvOutputPathForDestination(string currentCsvOutputPath, string destinationPath) + { + var normalizedCsvPath = NormalizeFilePathArgument(currentCsvOutputPath); + if (string.IsNullOrWhiteSpace(normalizedCsvPath)) + { + return currentCsvOutputPath; + } + + var directory = Path.GetDirectoryName(normalizedCsvPath); + var destinationFolderName = GetDestinationFolderName(destinationPath); + if (string.IsNullOrWhiteSpace(destinationFolderName)) + { + return normalizedCsvPath; + } + + var extension = Path.GetExtension(normalizedCsvPath); + if (string.IsNullOrWhiteSpace(extension)) + { + extension = ".csv"; + } + + var safeFileName = SanitizeFileName(destinationFolderName) + extension; + return string.IsNullOrWhiteSpace(directory) + ? safeFileName + : Path.Combine(directory, safeFileName); + } + + private static string GetDestinationFolderName(string destinationPath) + { + var normalizedDestinationPath = NormalizeDirectoryPathArgument(destinationPath); + if (string.IsNullOrWhiteSpace(normalizedDestinationPath)) + { + return string.Empty; + } + + var trimmedPath = normalizedDestinationPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.IsNullOrWhiteSpace(trimmedPath)) + { + return string.Empty; + } + + var rootPath = Path.GetPathRoot(trimmedPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!string.IsNullOrWhiteSpace(rootPath) + && string.Equals(trimmedPath, rootPath, StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + + return Path.GetFileName(trimmedPath); + } + + private static string SanitizeFileName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var invalidFileNameChars = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(Array.IndexOf(invalidFileNameChars, character) >= 0 ? '_' : character); + } + + return builder.ToString(); + } + private void SetUseFaceGpu(bool value) { var currentValue = _ai.UseFaceGpu;