develop #1

Open
maddo wants to merge 126 commits from develop into master
3 changed files with 214 additions and 0 deletions
Showing only changes of commit f3ac1ea920 - Show all commits

feat: Implement AI CSV overwrite confirmation and update CSV output path based on destination

Maddo 2026-05-24 18:45:51 +02:00

View file

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

View file

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

View file

@ -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<string> AvailableFonts { get; }
public List<string> 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<string, string, Task<bool>>? 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<bool> 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;