diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..3734723
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,90 @@
+# Copilot Instructions
+
+## Build & Test Commands
+
+```powershell
+# Build
+dotnet build Catalog.sln
+
+# Run all tests
+dotnet test MaddoShared.Tests
+
+# Run a single test
+dotnet test MaddoShared.Tests --filter "FullyQualifiedName~MethodName"
+
+# Benchmarks (modes: quick | all | parallel | chunks | sizes | stress)
+.\run-benchmarks.ps1 quick
+
+# Publish release build (self-contained Windows EXE)
+dotnet publish "imagecatalog\ImageCatalog 2.csproj" -c Release -r win-x64 --self-contained
+```
+
+## Architecture
+
+This is a WinForms/WPF image cataloging application targeting .NET 10.0-windows.
+
+### Projects
+
+| Project | Purpose |
+|---------|---------|
+| **imagecatalog** | Main desktop application — WinForms (default), WPF (`--wpf`), or Avalonia (`--avalonia`) |
+| **MaddoShared** | Shared image processing library (the core) |
+| **MaddoShared.Tests** | Unit tests for MaddoShared |
+| **MaddoShared.Benchmarks** | BenchmarkDotNet performance benchmarks |
+| **WPFCatalog** | Alternate WPF UI (secondary) |
+| **ImageCatalogCS / ImageCatalogParallel** | Legacy/experimental variants |
+| **CatalogLib / CatalogLibVb / CatalogVbLib** | Legacy VB.NET libraries |
+
+The main app selects its UI at startup via command-line flag:
+- *(default)* — WinForms (`MainForm`)
+- `--wpf` — WPF with MahApps.Metro (`MainWindow`)
+- `--avalonia` — Avalonia with Fluent theme (`AvaloniaMainWindow`)
+
+All three UIs bind to the same `DataModel`. Dialog events (`SelectSourceFolderRequested`, etc.) are subscribed in each window's code-behind. `DataModel.UiInvoker` must be set by the active UI to enable cross-thread UI updates (Avalonia sets this to `Dispatcher.UIThread.Invoke`; WPF uses `Application.Current.Dispatcher`).
+
+### Core Flow
+
+1. User configures paths/settings in the UI (`DataModel.cs` — MVVM ViewModel)
+2. `ProcessImagesCommand` triggers `ImageCreationService`
+3. `ImageCreationService` processes files in parallel chunks, with configurable concurrency and batch size (GC flush between chunks)
+4. Each file is handled by an `IImageCreator` implementation (GDI+ or ImageSharp)
+5. Output: resized/watermarked/overlaid images written to a destination folder hierarchy
+
+### Key Abstractions (MaddoShared)
+
+- **`IImageCreator`** — single async method to process one image; two implementations: `ImageCreatorGDI` (System.Drawing) and `ImageCreatorSharp` (SixLabors.ImageSharp)
+- **`ImageCreationService`** — parallel orchestrator; uses `AsyncEnumerator` with chunking; loads logo once, clones per thread for thread safety
+- **`ImageState`** — per-file processing context (input path, EXIF orientation, thumbnail sizes, overlays, logo, rotation)
+- **`PicSettings`** — 50+ property configuration model (dimensions, fonts, colors, JPEG quality, watermark, logo positioning, `ImageCreatorProvider` selector)
+- **`FileHelperSharp`** — recursive file enumeration with folder-per-N-files mapping and counter formatting
+
+### Implementation Selection
+
+`PicSettings.ImageCreatorProvider` switches between `"Sharp"` (SixLabors.ImageSharp) and `"Alternate"` (GDI+) at runtime.
+
+## Conventions
+
+### C# Style
+- File-scoped namespaces everywhere: `namespace MaddoShared;`
+- Nullable reference types enabled (`enable`)
+- Implicit usings enabled
+- `ConfigureAwait(false)` on all `await` calls in library code
+
+### Dependency Injection
+- Constructor injection throughout; loggers typed as `ILogger`
+- Main app wires services in `Program.cs` via `IServiceCollection`
+
+### Testing
+- MSTest with `[TestClass]` / `[TestMethod]`
+- FluentAssertions for assertions
+- Moq for mocking
+- Factory helper pattern in tests: `CreateService(Action configure = null)` methods for flexible test setup
+
+### Async / Parallelism
+- All image I/O is `async Task`
+- `ImageCreationService` uses configurable `MaxDegreeOfParallelism` and `ChunkSize`; explicit `GC.Collect()` between chunks to manage memory under batch load
+
+### Versioning & CI
+- Semantic versioning via **GitVersion** (mode: `ContinuousDelivery`, current base: `3.2.0`)
+- GitLab CI pipeline: builds → single-file self-contained EXE → GitLab Release
+- Private NuGet packages scoped to `AIFotoONLUS.*` prefix, routed to the GitLab package registry (see `NuGet.Config`)
diff --git a/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs b/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs
new file mode 100644
index 0000000..799c766
--- /dev/null
+++ b/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs
@@ -0,0 +1,30 @@
+using Catalog.Communication.Models;
+
+namespace Catalog.Communication.Abstractions;
+
+public interface IRaceUploadCommunicationClient
+{
+ Task LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
+
+ Task LogoutAdminAsync(CancellationToken cancellationToken = default);
+
+ Task UploadRaceImageAsync(RaceImageUploadRequest request, CancellationToken cancellationToken = default);
+
+ Task RemoveRaceImageAsync(RaceImageRemoveRequest request, CancellationToken cancellationToken = default);
+
+ Task UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default);
+
+ Task ExecuteGaraCommandAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default);
+
+ Task ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary formFields, CancellationToken cancellationToken = default);
+
+ Task ExecutePublicLogonAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default);
+
+ Task ExecuteUsersAsync(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default);
+
+ Task ExecuteFoto2Async(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default);
+
+ Task DownloadThumbnailAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default);
+
+ Task DownloadOriginalAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default);
+}
diff --git a/Catalog.Communication/Catalog.Communication.csproj b/Catalog.Communication/Catalog.Communication.csproj
new file mode 100644
index 0000000..cb8dc94
--- /dev/null
+++ b/Catalog.Communication/Catalog.Communication.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net10.0
+ Library
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/Catalog.Communication/CatalogCommunicationOptions.cs b/Catalog.Communication/CatalogCommunicationOptions.cs
new file mode 100644
index 0000000..ac83eea
--- /dev/null
+++ b/Catalog.Communication/CatalogCommunicationOptions.cs
@@ -0,0 +1,14 @@
+namespace Catalog.Communication;
+
+public sealed class CatalogCommunicationOptions
+{
+ public required Uri BaseUri { get; set; }
+
+ public string AdminPageBasePath { get; set; } = "admin/pg";
+
+ public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
+
+ public int RetryCount { get; set; } = 2;
+
+ public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromMilliseconds(250);
+}
diff --git a/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs b/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs
new file mode 100644
index 0000000..0090ca5
--- /dev/null
+++ b/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs
@@ -0,0 +1,46 @@
+using System.Net;
+using Catalog.Communication.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+
+namespace Catalog.Communication.DependencyInjection;
+
+public static class CatalogCommunicationServiceCollectionExtensions
+{
+ public static IServiceCollection AddCatalogCommunication(this IServiceCollection services, Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ services
+ .AddOptions()
+ .Configure(configure)
+ .Validate(o => o.BaseUri is not null, "CatalogCommunicationOptions.BaseUri is required.")
+ .Validate(o => !string.IsNullOrWhiteSpace(o.AdminPageBasePath), "AdminPageBasePath is required.")
+ .Validate(o => o.RequestTimeout > TimeSpan.Zero, "RequestTimeout must be greater than zero.")
+ .Validate(o => o.RetryCount >= 0 && o.RetryCount <= 10, "RetryCount must be between 0 and 10.")
+ .Validate(o => o.RetryBaseDelay > TimeSpan.Zero, "RetryBaseDelay must be greater than zero.");
+
+ services.TryAddSingleton();
+
+ services
+ .AddHttpClient((sp, client) =>
+ {
+ var options = sp.GetRequiredService>().Value;
+ client.BaseAddress = options.BaseUri;
+ })
+ .ConfigurePrimaryHttpMessageHandler(sp =>
+ {
+ var cookieContainer = sp.GetRequiredService();
+ return new HttpClientHandler
+ {
+ UseCookies = true,
+ CookieContainer = cookieContainer,
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
+ };
+ });
+
+ return services;
+ }
+}
diff --git a/Catalog.Communication/Models/AdminLoginRequest.cs b/Catalog.Communication/Models/AdminLoginRequest.cs
new file mode 100644
index 0000000..170faf4
--- /dev/null
+++ b/Catalog.Communication/Models/AdminLoginRequest.cs
@@ -0,0 +1,10 @@
+namespace Catalog.Communication.Models;
+
+public sealed class AdminLoginRequest
+{
+ public required string Login { get; init; }
+
+ public required string Password { get; init; }
+
+ public string Command { get; init; } = "check";
+}
diff --git a/Catalog.Communication/Models/AdminPhotoEndpoint.cs b/Catalog.Communication/Models/AdminPhotoEndpoint.cs
new file mode 100644
index 0000000..b1aad98
--- /dev/null
+++ b/Catalog.Communication/Models/AdminPhotoEndpoint.cs
@@ -0,0 +1,8 @@
+namespace Catalog.Communication.Models;
+
+public enum AdminPhotoEndpoint
+{
+ Foto = 0,
+ TipoGara = 1,
+ LogFoto = 2,
+}
diff --git a/Catalog.Communication/Models/MediaFileResponse.cs b/Catalog.Communication/Models/MediaFileResponse.cs
new file mode 100644
index 0000000..2bdfecd
--- /dev/null
+++ b/Catalog.Communication/Models/MediaFileResponse.cs
@@ -0,0 +1,16 @@
+using System.Net;
+
+namespace Catalog.Communication.Models;
+
+public sealed class MediaFileResponse
+{
+ public required HttpStatusCode StatusCode { get; init; }
+
+ public required byte[] Content { get; init; }
+
+ public string? ContentType { get; init; }
+
+ public string? FileName { get; init; }
+
+ public required IReadOnlyDictionary> Headers { get; init; }
+}
diff --git a/Catalog.Communication/Models/RaceFileUploadRequest.cs b/Catalog.Communication/Models/RaceFileUploadRequest.cs
new file mode 100644
index 0000000..83cfb3b
--- /dev/null
+++ b/Catalog.Communication/Models/RaceFileUploadRequest.cs
@@ -0,0 +1,16 @@
+namespace Catalog.Communication.Models;
+
+public sealed class RaceFileUploadRequest
+{
+ public int? CodFile { get; init; }
+
+ public long? Id { get; init; }
+
+ public required string FileName { get; init; }
+
+ public required Stream FileStream { get; init; }
+
+ public string FormFieldName { get; init; } = "fileName";
+
+ public string? ContentType { get; init; }
+}
diff --git a/Catalog.Communication/Models/RaceImageRemoveRequest.cs b/Catalog.Communication/Models/RaceImageRemoveRequest.cs
new file mode 100644
index 0000000..0e9698a
--- /dev/null
+++ b/Catalog.Communication/Models/RaceImageRemoveRequest.cs
@@ -0,0 +1,10 @@
+namespace Catalog.Communication.Models;
+
+public sealed class RaceImageRemoveRequest
+{
+ public required long Id { get; init; }
+
+ public required int CodImage { get; init; }
+
+ public int? TotImgNumber { get; init; }
+}
diff --git a/Catalog.Communication/Models/RaceImageUploadRequest.cs b/Catalog.Communication/Models/RaceImageUploadRequest.cs
new file mode 100644
index 0000000..ee049f1
--- /dev/null
+++ b/Catalog.Communication/Models/RaceImageUploadRequest.cs
@@ -0,0 +1,18 @@
+namespace Catalog.Communication.Models;
+
+public sealed class RaceImageUploadRequest
+{
+ public required long Id { get; init; }
+
+ public required int CodImage { get; init; }
+
+ public int? TotImgNumber { get; init; }
+
+ public required string FileName { get; init; }
+
+ public required Stream FileStream { get; init; }
+
+ public string FormFieldName { get; init; } = "imgFile";
+
+ public string? ContentType { get; init; }
+}
diff --git a/Catalog.Communication/Models/RawEndpointResponse.cs b/Catalog.Communication/Models/RawEndpointResponse.cs
new file mode 100644
index 0000000..0f0f36a
--- /dev/null
+++ b/Catalog.Communication/Models/RawEndpointResponse.cs
@@ -0,0 +1,12 @@
+using System.Net;
+
+namespace Catalog.Communication.Models;
+
+public sealed class RawEndpointResponse
+{
+ public required HttpStatusCode StatusCode { get; init; }
+
+ public required string Body { get; init; }
+
+ public required IReadOnlyDictionary> Headers { get; init; }
+}
diff --git a/Catalog.Communication/Models/UploadFileResponse.cs b/Catalog.Communication/Models/UploadFileResponse.cs
new file mode 100644
index 0000000..e68e400
--- /dev/null
+++ b/Catalog.Communication/Models/UploadFileResponse.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+
+namespace Catalog.Communication.Models;
+
+public sealed class UploadFileResponse
+{
+ [JsonPropertyName("result")]
+ public bool Result { get; init; }
+
+ [JsonPropertyName("message")]
+ public string Message { get; init; } = string.Empty;
+
+ [JsonPropertyName("fileName")]
+ public string FileName { get; init; } = string.Empty;
+
+ [JsonPropertyName("fileNameLink")]
+ public string FileNameLink { get; init; } = string.Empty;
+}
diff --git a/Catalog.Communication/Models/UploadImageResponse.cs b/Catalog.Communication/Models/UploadImageResponse.cs
new file mode 100644
index 0000000..5ecc353
--- /dev/null
+++ b/Catalog.Communication/Models/UploadImageResponse.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace Catalog.Communication.Models;
+
+public sealed class UploadImageResponse
+{
+ [JsonPropertyName("result")]
+ public bool Result { get; init; }
+
+ [JsonPropertyName("message")]
+ public string Message { get; init; } = string.Empty;
+
+ [JsonPropertyName("imgPath")]
+ public string ImgPath { get; init; } = string.Empty;
+}
diff --git a/Catalog.Communication/RaceUploadCommunicationClient.cs b/Catalog.Communication/RaceUploadCommunicationClient.cs
new file mode 100644
index 0000000..a5f9d49
--- /dev/null
+++ b/Catalog.Communication/RaceUploadCommunicationClient.cs
@@ -0,0 +1,506 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using Catalog.Communication.Abstractions;
+using Catalog.Communication.Models;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Catalog.Communication;
+
+public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient
+{
+ private const string AdminMenuPath = "admin/menu/Menu4.abl";
+ private const string PublicLogonPath = "Logon.abl";
+ private const string UsersPath = "Users.abl";
+ private const string Foto2Path = "Foto2.abl";
+ private const string ThumbnailPath = "foto";
+ private const string OriginalPath = "fotoOriginali";
+
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly IOptions _options;
+
+ public RaceUploadCommunicationClient(
+ HttpClient httpClient,
+ IOptions options,
+ ILogger logger)
+ {
+ _httpClient = httpClient;
+ _options = options;
+ _logger = logger;
+ }
+
+ public Task LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var formFields = new Dictionary
+ {
+ ["login"] = request.Login,
+ ["pwd"] = request.Password,
+ ["cmdIU"] = request.Command,
+ };
+
+ return PostFormAsync(AdminMenuPath, formFields, "admin-login", cancellationToken);
+ }
+
+ public Task LogoutAdminAsync(CancellationToken cancellationToken = default)
+ {
+ var formFields = new Dictionary
+ {
+ ["cmdIU"] = "login",
+ };
+
+ return PostFormAsync(AdminMenuPath, formFields, "admin-logout", cancellationToken);
+ }
+
+ public Task UploadRaceImageAsync(RaceImageUploadRequest request, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ return PostMultipartAndParseUploadAsync(
+ GetAdminPagePath("Gara"),
+ "gara-upload-image",
+ request.FileStream,
+ request.FileName,
+ request.FormFieldName,
+ request.ContentType,
+ static fields =>
+ {
+ fields["cmd"] = "loadImg";
+ },
+ new Dictionary
+ {
+ ["id"] = request.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["codImage"] = request.CodImage.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["totImgNumber"] = request.TotImgNumber?.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ },
+ cancellationToken);
+ }
+
+ public Task RemoveRaceImageAsync(RaceImageRemoveRequest request, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var fields = new Dictionary
+ {
+ ["cmd"] = "removeImg",
+ ["id"] = request.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["codImage"] = request.CodImage.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["totImgNumber"] = request.TotImgNumber?.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ };
+
+ return PostFormAndParseUploadAsync(GetAdminPagePath("Gara"), fields, "gara-remove-image", cancellationToken);
+ }
+
+ public Task UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ return PostMultipartAndParseUploadAsync(
+ GetAdminPagePath("Gara"),
+ "gara-upload-file",
+ request.FileStream,
+ request.FileName,
+ request.FormFieldName,
+ request.ContentType,
+ static fields =>
+ {
+ fields["cmd"] = "saveFile";
+ },
+ new Dictionary
+ {
+ ["codFile"] = request.CodFile?.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["id"] = request.Id?.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ },
+ cancellationToken);
+ }
+
+ public Task ExecuteGaraCommandAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default)
+ {
+ return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-command", cancellationToken);
+ }
+
+ public Task ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary formFields, CancellationToken cancellationToken = default)
+ {
+ var path = endpoint switch
+ {
+ AdminPhotoEndpoint.Foto => GetAdminPagePath("Foto"),
+ AdminPhotoEndpoint.TipoGara => GetAdminPagePath("TipoGara"),
+ AdminPhotoEndpoint.LogFoto => GetAdminPagePath("LogFoto"),
+ _ => throw new ArgumentOutOfRangeException(nameof(endpoint), endpoint, "Unsupported endpoint."),
+ };
+
+ return PostFormAsync(path, formFields, $"photo-admin-{endpoint}", cancellationToken);
+ }
+
+ public Task ExecutePublicLogonAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default)
+ {
+ return PostFormAsync(PublicLogonPath, formFields, "public-logon", cancellationToken);
+ }
+
+ public Task ExecuteUsersAsync(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default)
+ {
+ return SendCommandAsync(method, UsersPath, formFields, "public-users", cancellationToken);
+ }
+
+ public Task ExecuteFoto2Async(HttpMethod method, IReadOnlyDictionary? formFields = null, CancellationToken cancellationToken = default)
+ {
+ return SendCommandAsync(method, Foto2Path, formFields, "public-foto2", cancellationToken);
+ }
+
+ public Task DownloadThumbnailAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filename);
+ return DownloadFileAsync(ThumbnailPath, filename, idFoto, "media-thumbnail", cancellationToken);
+ }
+
+ public Task DownloadOriginalAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filename);
+ return DownloadFileAsync(OriginalPath, filename, idFoto, "media-original", cancellationToken);
+ }
+
+ private Task SendCommandAsync(HttpMethod method, string path, IReadOnlyDictionary? formFields, string operationName, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(method);
+
+ if (method == HttpMethod.Get)
+ {
+ var relativePath = AppendQuery(path, formFields);
+ return GetAsync(relativePath, operationName, cancellationToken);
+ }
+
+ if (method == HttpMethod.Post)
+ {
+ return PostFormAsync(path, formFields ?? new Dictionary(), operationName, cancellationToken);
+ }
+
+ throw new NotSupportedException($"Only GET and POST are supported. Requested method: {method}.");
+ }
+
+ private Task GetAsync(string relativePath, string operationName, CancellationToken cancellationToken)
+ {
+ return ExecuteWithResilienceAsync(
+ () => new HttpRequestMessage(HttpMethod.Get, relativePath),
+ ToRawResponseAsync,
+ operationName,
+ cancellationToken);
+ }
+
+ private Task PostFormAsync(string relativePath, IReadOnlyDictionary formFields, string operationName, CancellationToken cancellationToken)
+ {
+ return ExecuteWithResilienceAsync(
+ () =>
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, relativePath)
+ {
+ Content = BuildFormContent(formFields),
+ };
+
+ return request;
+ },
+ ToRawResponseAsync,
+ operationName,
+ cancellationToken);
+ }
+
+ private Task PostFormAndParseUploadAsync(string relativePath, IReadOnlyDictionary formFields, string operationName, CancellationToken cancellationToken)
+ {
+ return ExecuteWithResilienceAsync(
+ () =>
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, relativePath)
+ {
+ Content = BuildFormContent(formFields),
+ };
+
+ return request;
+ },
+ async (response, token) =>
+ {
+ var raw = await ToRawResponseAsync(response, token).ConfigureAwait(false);
+ return ParseSingleItemArray(raw.Body);
+ },
+ operationName,
+ cancellationToken);
+ }
+
+ private async Task PostMultipartAndParseUploadAsync(
+ string relativePath,
+ string operationName,
+ Stream fileStream,
+ string fileName,
+ string formFieldName,
+ string? contentType,
+ Action> configureRequiredFields,
+ IReadOnlyDictionary optionalFields,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fileStream);
+ ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(formFieldName);
+
+ var payload = await ReadAllBytesAsync(fileStream, cancellationToken).ConfigureAwait(false);
+
+ return await ExecuteWithResilienceAsync(
+ () =>
+ {
+ var multipart = new MultipartFormDataContent();
+ var fields = new Dictionary();
+ configureRequiredFields(fields);
+
+ foreach (var field in fields)
+ {
+ if (string.IsNullOrWhiteSpace(field.Value))
+ {
+ continue;
+ }
+
+ multipart.Add(new StringContent(field.Value, Encoding.UTF8), field.Key);
+ }
+
+ foreach (var field in optionalFields)
+ {
+ if (string.IsNullOrWhiteSpace(field.Value))
+ {
+ continue;
+ }
+
+ multipart.Add(new StringContent(field.Value, Encoding.UTF8), field.Key);
+ }
+
+ var fileContent = new ByteArrayContent(payload);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType ?? "application/octet-stream");
+ multipart.Add(fileContent, formFieldName, fileName);
+
+ return new HttpRequestMessage(HttpMethod.Post, relativePath)
+ {
+ Content = multipart,
+ };
+ },
+ async (response, token) =>
+ {
+ var raw = await ToRawResponseAsync(response, token).ConfigureAwait(false);
+ return ParseSingleItemArray(raw.Body);
+ },
+ operationName,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private Task DownloadFileAsync(string basePath, string filename, long? idFoto, string operationName, CancellationToken cancellationToken)
+ {
+ var escapedFileName = Uri.EscapeDataString(filename);
+ var relativePath = idFoto.HasValue
+ ? $"{basePath}/{escapedFileName}?id_foto={idFoto.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
+ : $"{basePath}/{escapedFileName}";
+
+ return ExecuteWithResilienceAsync(
+ () => new HttpRequestMessage(HttpMethod.Get, relativePath),
+ async (response, token) =>
+ {
+ var content = await response.Content.ReadAsByteArrayAsync(token).ConfigureAwait(false);
+ return new MediaFileResponse
+ {
+ StatusCode = response.StatusCode,
+ Content = content,
+ ContentType = response.Content.Headers.ContentType?.MediaType,
+ FileName = response.Content.Headers.ContentDisposition?.FileNameStar ?? response.Content.Headers.ContentDisposition?.FileName,
+ Headers = BuildHeaders(response),
+ };
+ },
+ operationName,
+ cancellationToken);
+ }
+
+ private async Task ExecuteWithResilienceAsync(
+ Func requestFactory,
+ Func> responseFactory,
+ string operationName,
+ CancellationToken cancellationToken)
+ {
+ var options = _options.Value;
+ Exception? lastException = null;
+
+ for (var attempt = 0; attempt <= options.RetryCount; attempt++)
+ {
+ using var request = requestFactory();
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(options.RequestTimeout);
+
+ HttpResponseMessage? response = null;
+
+ try
+ {
+ response = await _httpClient
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeoutCts.Token)
+ .ConfigureAwait(false);
+
+ if (IsRetryableStatusCode(response.StatusCode) && attempt < options.RetryCount)
+ {
+ _logger.LogWarning(
+ "Operation {OperationName} received retryable status code {StatusCode} at attempt {Attempt}/{MaxAttempts}.",
+ operationName,
+ (int)response.StatusCode,
+ attempt + 1,
+ options.RetryCount + 1);
+
+ await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false);
+ continue;
+ }
+
+ return await responseFactory(response, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ lastException = ex;
+
+ if (attempt < options.RetryCount)
+ {
+ _logger.LogWarning(ex, "Operation {OperationName} timed out at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1);
+ await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false);
+ continue;
+ }
+
+ _logger.LogError(ex, "Operation {OperationName} timed out after {MaxAttempts} attempts.", operationName, options.RetryCount + 1);
+ throw;
+ }
+ catch (HttpRequestException ex)
+ {
+ lastException = ex;
+
+ if (attempt < options.RetryCount)
+ {
+ _logger.LogWarning(ex, "Operation {OperationName} failed with transient HTTP error at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1);
+ await DelayBeforeRetryAsync(options, attempt, cancellationToken).ConfigureAwait(false);
+ continue;
+ }
+
+ _logger.LogError(ex, "Operation {OperationName} failed with HTTP error after {MaxAttempts} attempts.", operationName, options.RetryCount + 1);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Operation {OperationName} failed with unexpected error at attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, options.RetryCount + 1);
+ throw;
+ }
+ finally
+ {
+ response?.Dispose();
+ }
+ }
+
+ throw new HttpRequestException($"Operation '{operationName}' failed after {options.RetryCount + 1} attempts.", lastException);
+ }
+
+ private static bool IsRetryableStatusCode(HttpStatusCode statusCode)
+ {
+ return statusCode is HttpStatusCode.RequestTimeout
+ or HttpStatusCode.TooManyRequests
+ or HttpStatusCode.BadGateway
+ or HttpStatusCode.ServiceUnavailable
+ or HttpStatusCode.GatewayTimeout
+ or HttpStatusCode.InternalServerError;
+ }
+
+ private static async Task DelayBeforeRetryAsync(CatalogCommunicationOptions options, int attempt, CancellationToken cancellationToken)
+ {
+ var delay = TimeSpan.FromMilliseconds(options.RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt));
+ await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static FormUrlEncodedContent BuildFormContent(IReadOnlyDictionary formFields)
+ {
+ var pairs = formFields
+ .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
+ .Select(kvp => new KeyValuePair(kvp.Key, kvp.Value!));
+
+ return new FormUrlEncodedContent(pairs);
+ }
+
+ private static string AppendQuery(string path, IReadOnlyDictionary? query)
+ {
+ if (query is null || query.Count == 0)
+ {
+ return path;
+ }
+
+ var encodedPairs = query
+ .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
+ .Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value!)}")
+ .ToArray();
+
+ if (encodedPairs.Length == 0)
+ {
+ return path;
+ }
+
+ var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?";
+ return string.Concat(path, separator, string.Join("&", encodedPairs));
+ }
+
+ private static async Task ToRawResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+
+ return new RawEndpointResponse
+ {
+ StatusCode = response.StatusCode,
+ Body = body,
+ Headers = BuildHeaders(response),
+ };
+ }
+
+ private static IReadOnlyDictionary> BuildHeaders(HttpResponseMessage response)
+ {
+ var headers = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var header in response.Headers)
+ {
+ headers[header.Key] = header.Value.ToArray();
+ }
+
+ foreach (var header in response.Content.Headers)
+ {
+ headers[header.Key] = header.Value.ToArray();
+ }
+
+ return headers;
+ }
+
+ private static TUpload? ParseSingleItemArray(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ {
+ return default;
+ }
+
+ var items = JsonSerializer.Deserialize>(json, JsonOptions);
+ return items is { Count: > 0 } ? items[0] : default;
+ }
+
+ private static async Task ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken)
+ {
+ if (stream.CanSeek)
+ {
+ stream.Position = 0;
+ }
+
+ using var memoryStream = new MemoryStream();
+ await stream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
+ return memoryStream.ToArray();
+ }
+
+ private string GetAdminPagePath(string pageName)
+ {
+ var basePath = _options.Value.AdminPageBasePath.Trim('/');
+ return $"{basePath}/{pageName}.abl";
+ }
+}
diff --git a/Catalog.sln b/Catalog.sln
index a5fd6f7..b92613e 100644
--- a/Catalog.sln
+++ b/Catalog.sln
@@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Tests", "MaddoS
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Benchmarks", "MaddoShared.Benchmarks\MaddoShared.Benchmarks.csproj", "{07499348-8C15-4DCC-8316-4AD121A43C38}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Catalog.Communication", "Catalog.Communication\Catalog.Communication.csproj", "{EF5D3B7E-F380-4976-A0A9-085FEA157F79}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -73,12 +75,25 @@ Global
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x64.Build.0 = Release|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x86.ActiveCfg = Release|Any CPU
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x86.Build.0 = Release|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x64.Build.0 = Debug|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Debug|x86.Build.0 = Debug|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x64.ActiveCfg = Release|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x64.Build.0 = Release|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x86.ActiveCfg = Release|Any CPU
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{AEBFE9E3-277C-4A7B-8448-145D1B11998B} = {A3D50937-74F6-4DC8-8D89-B534B484C0F9}
+ {EF5D3B7E-F380-4976-A0A9-085FEA157F79} = {A3D50937-74F6-4DC8-8D89-B534B484C0F9}
{59952BE8-20B4-4BF2-9367-705F41395265} = {5F0BEF23-B1EA-4100-A772-DC455D40B1C1}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
diff --git a/docs/api-race-upload-spec.md b/docs/api-race-upload-spec.md
new file mode 100644
index 0000000..0748114
--- /dev/null
+++ b/docs/api-race-upload-spec.md
@@ -0,0 +1,230 @@
+# API Specification (code-backed): races, images, and authentication
+
+## Scope and evidence
+
+This document is reverse-engineered from:
+- JSP/JS usage in the web app
+- `WEB-INF/web.xml` servlet mappings
+- decompiled sources in `WEB-INF/lib/*_src` (notably `AblServletSvlt`, `AcServlet`, `GaraSvlt`, `Logon4Svlt`, `Menu4Svlt`, `GetFileTnSvlt`)
+
+The upload response schemas below are now **confirmed from code**, not inferred.
+
+## Base URL
+
+Examples use:
+- `https://your-host`
+
+## Authentication (how to obtain the usable token)
+
+## Actual auth model
+
+This app uses **server session authentication**, not JWT bearer tokens.
+
+Login success sets session attributes:
+- `loginUser_id` (Long)
+- `utenteLogon` (user object)
+
+For automation, the practical token is the **session cookie** (`JSESSIONID` and any app cookies).
+
+## Admin login endpoint
+
+- `POST /admin/menu/Menu4.abl`
+- Servlet mapping: `com.ablia.anag.servlet.Menu4Svlt` (extends `Logon4Svlt`)
+- Body (`application/x-www-form-urlencoded`):
+ - `login`
+ - `pwd`
+ - `cmdIU=check`
+
+Other `cmdIU` values used:
+- `cmdIU=login` (logout)
+- `cmdIU=np` (password change)
+- `cmdIU=checkSso` (SSO branch if enabled)
+
+### cURL login example (capture session cookie)
+
+```bash
+curl -i -c cookies.txt -X POST "https://your-host/admin/menu/Menu4.abl" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ --data "login=YOUR_USER&pwd=YOUR_PASSWORD&cmdIU=check"
+```
+
+Reuse cookie jar for authenticated calls:
+
+```bash
+curl -i -b cookies.txt "https://your-host/admin/pg/Gara.abl?cmd=search"
+```
+
+## Token note (Bearer/Basic)
+
+- No `Authorization: Bearer ...` flow is present in inspected sources.
+- A helper exists to parse `Authorization: Basic` (`getBasicAuthorizationHeaders`), but no race/photo endpoint uses it directly.
+
+## Relevant endpoints
+
+## Admin race/photo endpoints
+
+- `POST|GET /admin/pg/Gara.abl`
+- `POST|GET /admin/pg/Foto.abl`
+- `POST|GET /admin/pg/TipoGara.abl`
+- `POST|GET /admin/pg/LogFoto.abl`
+
+Deployment caveat:
+- some deployments expose the same admin pages under `POST|GET /admin/pg_RUS/*.abl`.
+- On `https://www.regalamiunsorriso.it`, post-login dashboard links point to `/admin/pg_RUS/Gara.abl` (not `/admin/pg/Gara.abl`).
+
+## Public/web endpoints related to photos/users
+
+- `POST|GET /Foto2.abl`
+- `POST|GET /Logon.abl` (mapped to `com.ablia.pg.servlet.Logon2Svlt`)
+- `POST|GET /Users.abl`
+
+Note: `/Login.abl` in `web.xml` is mapped to cart servlet (`CartSvlt`), not admin login.
+
+## File serving endpoints
+
+- `GET /foto/*` → `GetFileTnSvlt` (thumbnail by default)
+- `GET /fotoOriginali/*` → `GetFileOrigSvlt` (original file flow)
+
+## Multipart upload contract (common engine)
+
+All race-image/file uploads go through `AblServletSvlt.manageImgFileMultipartRequest` + `AcServlet.manageMultipartRequestParameters`.
+
+Key behavior:
+- File fields explicitly recognized: `imgFile`, `nomeFile`
+- UI also sends `fileName` for generic file upload; this still works (stored by original filename)
+- Temporary target directory: `DOCBASE + tmp/` (`getPathTmp()`)
+- Per-upload file size target: about `20000 KB` for this servlet path
+
+## Upload race image
+
+Endpoint:
+- `POST /admin/pg/Gara.abl`
+
+Required form fields:
+- `cmd=loadImg`
+- `imgFile` (binary)
+- `id` (race id)
+- `codImage` (slot index)
+- `totImgNumber` (typically `3` in UI)
+
+Storage details:
+- Race attachment path from `Gara.getPathAttach()` is `_img/_gara/`
+- Thumbnail is generated into `_img/_gara/100/` (100x75)
+
+### Response schema (confirmed)
+
+Response is JSON array with one object (`JsonUploadImageResponse`):
+- `result` (boolean)
+- `message` (string)
+- `imgPath` (string)
+
+Example success payload shape:
+
+```json
+[
+ {
+ "result": true,
+ "message": "...Immagine 1 Salvata...",
+ "imgPath": "../../_img/_gara/100/"
+ }
+]
+```
+
+## Delete race image
+
+Endpoint:
+- `POST /admin/pg/Gara.abl`
+
+Fields:
+- `cmd=removeImg`
+- `id`
+- `codImage`
+- `totImgNumber`
+
+Response schema is the same `JsonUploadImageResponse[]`.
+
+## Upload CSV/file for race processing
+
+Endpoint:
+- `POST /admin/pg/Gara.abl`
+
+UI helper (`Ab.saveFile`) sends:
+- `cmd=saveFile`
+- `fileName` (binary file)
+- `codFile` (slot id, usually `1`)
+- `id` (often `0` in UI flow)
+
+### Response schema (confirmed)
+
+JSON array with one `JsonUploadFileResponse` object:
+- `result` (boolean)
+- `message` (string)
+- `fileName` (stored/original name)
+- `fileNameLink` (typically `../../tmp/`)
+
+Example shape:
+
+```json
+[
+ {
+ "result": true,
+ "message": "...",
+ "fileName": "punti-foto.csv",
+ "fileNameLink": "../../tmp/punti-foto.csv"
+ }
+]
+```
+
+## Race command API (`/admin/pg/Gara.abl`)
+
+Confirmed custom commands in `GaraSvlt`:
+- `addPuntoFoto`
+- `delPuntoFoto`
+- `modPuntoFoto`
+- `indexFoto`
+- `noIndexFoto`
+- `creaPuntiFoto`
+- `indexCsvPisa`
+- `salvaFileCsv`
+
+Important parameters by command:
+- `id_gara` for race-level operations
+- `id_puntoFoto` and `id_puntoFotoIdx` for point selection
+- point fields such as `descrizionePuntoFoto`, `pathRelativoFoto`, `tipoPuntoFoto`
+
+CSV flow coupling:
+1. `cmd=saveFile` uploads to `tmp/` and returns `fileName`
+2. UI stores it into hidden field `fileNameOnServer_1`
+3. `cmd=salvaFileCsv` copies `DOCBASE/tmp/` to `DOCBASE/admin/csv/.csv`
+4. `cmd=indexCsvPisa` reads `admin/csv/.csv` via `Gara.getImpCsvFileName()` and updates matching photos
+
+## Image retrieval behavior
+
+`/foto/*` (`GetFileTnSvlt`):
+- accepts `id_foto` query parameter, or extracts id from URL suffix pattern like `name-.jpg`
+- if photo not found, serves `_img/_imgNotFound.png`
+- returns thumbnail by default in this servlet flow
+
+`/fotoOriginali/*` (`GetFileOrigSvlt`):
+- routes to original-file logic
+- applies user/account checks (valid user, not expired, max photos)
+- blocks some originals by filename markers (`_X`, `_Y`, `_Z`) depending on profile
+- logs photo view events when original is served
+
+## Known response caveat
+
+Do not treat `result=true` as universally reliable for success semantics:
+- in some error branches (`_loadImg`, `_removeImg`, `_saveFile`), code still returns `result=true` with an error message.
+
+For robust clients, validate both:
+- `result`
+- `message` text + expected output field (`imgPath` / `fileName` non-empty)
+
+## Practical automation sequence
+
+1. Login on `/admin/menu/Menu4.abl` with `cmdIU=check`, store cookies.
+2. Create/update race data on `/admin/pg/Gara.abl` with regular form commands.
+3. Upload images with `cmd=loadImg`.
+4. (Optional) upload CSV with `cmd=saveFile`.
+5. Finalize CSV placement with `cmd=salvaFileCsv` and run `cmd=indexCsvPisa`.
+6. Read thumbnails from `/foto/*` and originals from `/fotoOriginali/*`.
diff --git a/docs/openapi-race-upload.yaml b/docs/openapi-race-upload.yaml
new file mode 100644
index 0000000..3266182
--- /dev/null
+++ b/docs/openapi-race-upload.yaml
@@ -0,0 +1,679 @@
+openapi: 3.0.3
+info:
+ title: RUS Reverse-Engineered API (Races, Uploads, Auth)
+ version: 1.0.0
+ description: |
+ OpenAPI specification inferred from JSP/JS usage, `WEB-INF/web.xml`, and decompiled sources
+ in `WEB-INF/lib/*_src`.
+
+ This application is command-driven (`*.abl`) and many endpoints return server-rendered HTML.
+ Where the implementation returns JSON payloads (notably upload operations), schemas are modeled
+ explicitly from decompiled DTOs:
+
+ - `JsonUploadImageResponse { result, message, imgPath }`
+ - `JsonUploadFileResponse { result, message, fileName, fileNameLink }`
+
+ ## Important caveats
+ - Authentication is session-cookie based (typically `JSESSIONID`), not JWT bearer.
+ - Some error branches in upload handlers still return `result: true` with an error message text.
+ Clients should validate both `result` and semantic fields (`imgPath`, `fileName`).
+ - Several command endpoints primarily render JSP/HTML, so response contracts are best-effort.
+ - Some deployments namespace admin page endpoints as `/admin/pg_RUS/*` instead of `/admin/pg/*`.
+ Example observed deployment: `https://www.regalamiunsorriso.it` links `/admin/pg_RUS/Gara.abl` from dashboard.
+
+servers:
+ - url: https://your-host
+ description: Replace with your deployed host
+
+tags:
+ - name: Authentication
+ description: Session login/logout and related command flows
+ - name: RaceAdmin
+ description: Admin race management (`/admin/pg/Gara.abl`) including uploads and command actions
+ - name: PhotoAdmin
+ description: Admin photo/type/log endpoints (command-driven)
+ - name: Media
+ description: Thumbnail/original photo retrieval
+ - name: Public
+ description: Public-facing login/user/photo endpoints mapped in web.xml
+
+security:
+ - SessionCookieAuth: []
+
+paths:
+ /admin/menu/Menu4.abl:
+ post:
+ tags: [Authentication]
+ summary: Admin login/logout/password command endpoint
+ description: |
+ Command-driven login servlet (`Menu4Svlt` extending `Logon4Svlt`).
+
+ Primary commands (`cmdIU`):
+ - `check` => login
+ - `login` => logout
+ - `np` => password-change flow
+ - `checkSso` => SSO branch (when enabled)
+ security: []
+ requestBody:
+ required: true
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/Menu4CommandRequest'
+ examples:
+ login:
+ summary: Login
+ value:
+ login: YOUR_USER
+ pwd: YOUR_PASSWORD
+ cmdIU: check
+ logout:
+ summary: Logout
+ value:
+ cmdIU: login
+ passwordChange:
+ summary: Password change flow
+ value:
+ cmdIU: np
+ login: YOUR_USER
+ pwd: YOUR_OLD_PASSWORD
+ responses:
+ '200':
+ description: |
+ Usually HTML/JSP response with redirect or rendered page; on successful login, session cookies are set.
+ headers:
+ Set-Cookie:
+ description: Session cookie(s), usually including `JSESSIONID`
+ schema:
+ type: string
+ content:
+ text/html:
+ schema:
+ type: string
+ examples:
+ loginOk:
+ summary: Typical successful login page/redirect body
+ value: "...LOGIN_OK..."
+ '401':
+ description: Authentication failure semantics are typically encoded in HTML/message, not strict HTTP 401.
+
+ /admin/pg/Gara.abl:
+ get:
+ tags: [RaceAdmin]
+ summary: Race admin endpoint (command/query rendering)
+ description: |
+ Command endpoint for race administration. GET is commonly used for page loads/search rendering.
+ Typical query parameters include `cmd`, `act`, `id_gara`, pagination fields.
+ parameters:
+ - $ref: '#/components/parameters/Cmd'
+ - $ref: '#/components/parameters/Act'
+ - $ref: '#/components/parameters/IdGara'
+ responses:
+ '200':
+ description: Server-rendered HTML/JSP response
+ content:
+ text/html:
+ schema:
+ type: string
+ post:
+ tags: [RaceAdmin]
+ summary: Race admin command endpoint (multipart and form-urlencoded)
+ description: |
+ Supports both:
+ 1) `multipart/form-data` upload commands (`loadImg`, `removeImg`, `saveFile`)
+ 2) `application/x-www-form-urlencoded` action commands (`addPuntoFoto`, `indexCsvPisa`, ...)
+
+ Source evidence:
+ - `AblServletSvlt._loadImg/_removeImg/_saveFile`
+ - `GaraSvlt` custom command methods
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ oneOf:
+ - $ref: '#/components/schemas/GaraLoadImgMultipartRequest'
+ - $ref: '#/components/schemas/GaraRemoveImgMultipartRequest'
+ - $ref: '#/components/schemas/GaraSaveFileMultipartRequest'
+ encoding:
+ imgFile:
+ contentType: image/*
+ fileName:
+ contentType: '*/*'
+ examples:
+ loadImg:
+ summary: Upload race image slot
+ value:
+ cmd: loadImg
+ id: 123
+ codImage: 1
+ totImgNumber: 3
+ removeImg:
+ summary: Remove race image slot
+ value:
+ cmd: removeImg
+ id: 123
+ codImage: 1
+ totImgNumber: 3
+ saveFile:
+ summary: Upload CSV/import file
+ value:
+ cmd: saveFile
+ codFile: 1
+ id: 0
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/GaraCommandRequest'
+ examples:
+ addPuntoFoto:
+ value:
+ cmd: addPuntoFoto
+ id_gara: 123
+ descrizionePuntoFoto: "Start"
+ pathRelativoFoto: "start/"
+ tipoPuntoFoto: "A"
+ indexFoto:
+ value:
+ cmd: indexFoto
+ id_gara: 123
+ id_puntoFotoIdx: 987
+ noIndexFoto:
+ value:
+ cmd: noIndexFoto
+ id_gara: 123
+ id_puntoFotoIdx: 987
+ creaPuntiFoto:
+ value:
+ cmd: creaPuntiFoto
+ id_gara: 123
+ indexCsvPisa:
+ value:
+ cmd: indexCsvPisa
+ id_gara: 123
+ salvaFileCsv:
+ value:
+ cmd: salvaFileCsv
+ id_gara: 123
+ fileNameOnServer_1: "punti-foto.csv"
+ responses:
+ '200':
+ description: |
+ Mixed response style depending on `cmd`.
+ - Upload commands generally return JSON array with one object.
+ - Many non-upload commands render HTML/JSP with server messages.
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - $ref: '#/components/schemas/UploadImageResponseArray'
+ - $ref: '#/components/schemas/UploadFileResponseArray'
+ examples:
+ imageUploadOk:
+ value:
+ - result: true
+ message: "Immagine 1 Salvata"
+ imgPath: "../../_img/_gara/100/123_1_1700000000000.jpg"
+ imageUploadErrorStyle:
+ summary: Known caveat branch
+ value:
+ - result: true
+ message: "Immagine NON Salvata. Utente non valido"
+ imgPath: "../../"
+ fileUploadOk:
+ value:
+ - result: true
+ message: "File punti-foto.csv Salvato"
+ fileName: "punti-foto.csv"
+ fileNameLink: "../../tmp/punti-foto.csv"
+ text/html:
+ schema:
+ type: string
+ examples:
+ htmlCommandResponse:
+ value: "...messaggi/bean rendering..."
+
+ /admin/pg/Foto.abl:
+ get:
+ tags: [PhotoAdmin]
+ summary: Admin photo listing/search endpoint
+ responses:
+ '200':
+ description: Server-rendered HTML/JSP
+ content:
+ text/html:
+ schema:
+ type: string
+ post:
+ tags: [PhotoAdmin]
+ summary: Admin photo command endpoint
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/GenericCommandRequest'
+ responses:
+ '200':
+ description: Server-rendered HTML/JSP
+ content:
+ text/html:
+ schema:
+ type: string
+
+ /admin/pg/TipoGara.abl:
+ get:
+ tags: [PhotoAdmin]
+ summary: Admin race-type endpoint
+ responses:
+ '200':
+ description: Server-rendered HTML/JSP
+ content:
+ text/html:
+ schema:
+ type: string
+ post:
+ tags: [PhotoAdmin]
+ summary: Admin race-type command endpoint
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/GenericCommandRequest'
+ responses:
+ '200':
+ description: Server-rendered HTML/JSP
+ content:
+ text/html:
+ schema:
+ type: string
+
+ /admin/pg/LogFoto.abl:
+ get:
+ tags: [PhotoAdmin]
+ summary: Admin photo-log endpoint
+ responses:
+ '200':
+ description: Server-rendered HTML/JSP
+ content:
+ text/html:
+ schema:
+ type: string
+ post:
+ tags: [PhotoAdmin]
+ summary: Admin photo-log command endpoint
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/GenericCommandRequest'
+ responses:
+ '200':
+ description: Server-rendered HTML/JSP
+ content:
+ text/html:
+ schema:
+ type: string
+
+ /foto/{filename}:
+ get:
+ tags: [Media]
+ summary: Get thumbnail/photo by path; supports id_foto query
+ description: |
+ Mapped to `GetFileTnSvlt`.
+ Behavior:
+ - If `id_foto` is provided, photo is loaded by id.
+ - If missing, servlet may parse id from `{filename}` suffix pattern `name-.`.
+ - If not found, fallback `_img/_imgNotFound.png`.
+ security:
+ - SessionCookieAuth: []
+ parameters:
+ - name: filename
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Requested filename segment (also used for id parsing fallback)
+ - name: id_foto
+ in: query
+ required: false
+ schema:
+ type: integer
+ format: int64
+ description: Photo id (preferred)
+ responses:
+ '200':
+ description: Image bytes or HTML error text based on access checks
+ content:
+ image/jpeg:
+ schema:
+ type: string
+ format: binary
+ image/png:
+ schema:
+ type: string
+ format: binary
+ text/html:
+ schema:
+ type: string
+ examples:
+ blocked:
+ value: "Attenzione!. Questa Foto non puo' essere scaricata!!"
+
+ /fotoOriginali/{filename}:
+ get:
+ tags: [Media]
+ summary: Get original photo (with profile/account restrictions)
+ description: |
+ Mapped to `GetFileOrigSvlt` (delegates to original-file flow).
+ Includes additional checks:
+ - account validity/expiry/max-photo constraints
+ - filename marker restrictions (`_X`, `_Y`, `_Z`) by profile
+ - logs access events on successful original retrieval
+ security:
+ - SessionCookieAuth: []
+ parameters:
+ - name: filename
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: id_foto
+ in: query
+ required: false
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Image bytes or access-denied HTML text
+ content:
+ image/jpeg:
+ schema:
+ type: string
+ format: binary
+ image/png:
+ schema:
+ type: string
+ format: binary
+ text/html:
+ schema:
+ type: string
+ examples:
+ denied:
+ value: "Attenzione!. Account scaduto o raggiunto n. foto massimo"
+
+ /Logon.abl:
+ post:
+ tags: [Public]
+ summary: Public login endpoint
+ description: Mapped to `com.ablia.pg.servlet.Logon2Svlt`.
+ security: []
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/PublicLogonRequest'
+ responses:
+ '200':
+ description: HTML/JSP response with session handling
+ content:
+ text/html:
+ schema:
+ type: string
+
+ /Users.abl:
+ get:
+ tags: [Public]
+ summary: Public users endpoint
+ responses:
+ '200':
+ description: Implementation-specific output (usually HTML/JSP)
+ content:
+ text/html:
+ schema:
+ type: string
+ post:
+ tags: [Public]
+ summary: Public users command endpoint
+ requestBody:
+ content:
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/GenericCommandRequest'
+ responses:
+ '200':
+ description: Implementation-specific output (usually HTML/JSP)
+ content:
+ text/html:
+ schema:
+ type: string
+
+components:
+ securitySchemes:
+ SessionCookieAuth:
+ type: apiKey
+ in: cookie
+ name: JSESSIONID
+ description: |
+ Session cookie set by login endpoints. Some deployments can set additional cookies.
+
+ parameters:
+ Cmd:
+ name: cmd
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Command token for the servlet command-dispatch model
+ Act:
+ name: act
+ in: query
+ required: false
+ schema:
+ type: string
+ description: Secondary action token used by page flows
+ IdGara:
+ name: id_gara
+ in: query
+ required: false
+ schema:
+ type: integer
+ format: int64
+
+ schemas:
+ Menu4CommandRequest:
+ type: object
+ properties:
+ login:
+ type: string
+ pwd:
+ type: string
+ format: password
+ cmdIU:
+ type: string
+ enum: [check, login, np, checkSso, checkCC, ckcclnk, cmcc, ni]
+ actIU:
+ type: string
+ cmd2:
+ type: string
+ act2:
+ type: string
+ sso:
+ type: string
+ required: [cmdIU]
+
+ PublicLogonRequest:
+ type: object
+ properties:
+ login:
+ type: string
+ pwd:
+ type: string
+ cmdIU:
+ type: string
+ enum: [check, login, np, checkSso, checkCC, ckcclnk, cmcc]
+ cmd:
+ type: string
+ description: May also route custom commands like `logout` in `Logon2Svlt`
+
+ GenericCommandRequest:
+ type: object
+ properties:
+ cmd:
+ type: string
+ act:
+ type: string
+ cmd2:
+ type: string
+ act2:
+ type: string
+ _id:
+ type: string
+ _id_name:
+ type: string
+ pageNumber:
+ type: integer
+ totPageNumber:
+ type: integer
+ flgReport:
+ type: string
+ additionalProperties: true
+
+ GaraCommandRequest:
+ allOf:
+ - $ref: '#/components/schemas/GenericCommandRequest'
+ - type: object
+ properties:
+ cmd:
+ type: string
+ enum:
+ - addPuntoFoto
+ - delPuntoFoto
+ - modPuntoFoto
+ - indexFoto
+ - noIndexFoto
+ - creaPuntiFoto
+ - indexCsvPisa
+ - salvaFileCsv
+ - aggiornaThreadMsg
+ - search
+ - md
+ - ni
+ - refresh
+ id_gara:
+ type: integer
+ format: int64
+ id_puntoFoto:
+ type: integer
+ format: int64
+ id_puntoFotoIdx:
+ type: integer
+ format: int64
+ descrizionePuntoFoto:
+ type: string
+ pathRelativoFoto:
+ type: string
+ tipoPuntoFoto:
+ type: string
+ fileNameOnServer_1:
+ type: string
+ pathBase:
+ type: string
+ descrizione:
+ type: string
+ dataGaraInizio:
+ type: string
+ format: date
+ dataGaraFine:
+ type: string
+ format: date
+
+ GaraLoadImgMultipartRequest:
+ type: object
+ required: [cmd, imgFile, id, codImage]
+ properties:
+ cmd:
+ type: string
+ enum: [loadImg]
+ imgFile:
+ type: string
+ format: binary
+ id:
+ type: integer
+ format: int64
+ description: Race id (maps to bean primary key in upload flow)
+ codImage:
+ type: integer
+ description: Image slot index (UI uses 1..3)
+ totImgNumber:
+ type: integer
+ description: Total image slots for timestamp rotation logic
+
+ GaraRemoveImgMultipartRequest:
+ type: object
+ required: [cmd, id, codImage]
+ properties:
+ cmd:
+ type: string
+ enum: [removeImg]
+ id:
+ type: integer
+ format: int64
+ codImage:
+ type: integer
+ totImgNumber:
+ type: integer
+
+ GaraSaveFileMultipartRequest:
+ type: object
+ required: [cmd, fileName]
+ properties:
+ cmd:
+ type: string
+ enum: [saveFile]
+ fileName:
+ type: string
+ format: binary
+ description: Field used by JS helper (`Ab.saveFile`)
+ codFile:
+ type: integer
+ id:
+ type: integer
+ format: int64
+
+ JsonUploadImageResponse:
+ type: object
+ properties:
+ result:
+ type: boolean
+ message:
+ type: string
+ imgPath:
+ type: string
+ required: [result, message, imgPath]
+
+ JsonUploadFileResponse:
+ type: object
+ properties:
+ result:
+ type: boolean
+ message:
+ type: string
+ fileName:
+ type: string
+ fileNameLink:
+ type: string
+ required: [result, message, fileName, fileNameLink]
+
+ UploadImageResponseArray:
+ type: array
+ minItems: 1
+ maxItems: 1
+ items:
+ $ref: '#/components/schemas/JsonUploadImageResponse'
+
+ UploadFileResponseArray:
+ type: array
+ minItems: 1
+ maxItems: 1
+ items:
+ $ref: '#/components/schemas/JsonUploadFileResponse'
diff --git a/imagecatalog/AvaloniaApp.axaml.cs b/imagecatalog/AvaloniaApp.axaml.cs
index 9a14f4d..474d541 100644
--- a/imagecatalog/AvaloniaApp.axaml.cs
+++ b/imagecatalog/AvaloniaApp.axaml.cs
@@ -12,8 +12,7 @@ public partial class AvaloniaApp : Avalonia.Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
- var model = Program.ServiceProvider.GetRequiredService();
- desktop.MainWindow = new AvaloniaMainWindow(model);
+ desktop.MainWindow = Program.ServiceProvider.GetRequiredService();
}
base.OnFrameworkInitializationCompleted();
}
diff --git a/imagecatalog/AvaloniaMainWindow.axaml b/imagecatalog/AvaloniaMainWindow.axaml
index e47c50d..6229707 100644
--- a/imagecatalog/AvaloniaMainWindow.axaml
+++ b/imagecatalog/AvaloniaMainWindow.axaml
@@ -270,6 +270,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/imagecatalog/AvaloniaMainWindow.axaml.cs b/imagecatalog/AvaloniaMainWindow.axaml.cs
index 2095407..f4887c8 100644
--- a/imagecatalog/AvaloniaMainWindow.axaml.cs
+++ b/imagecatalog/AvaloniaMainWindow.axaml.cs
@@ -5,20 +5,43 @@ using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
+using Catalog.Communication.Abstractions;
+using Catalog.Communication.Models;
+using ImageCatalog;
+using Microsoft.Extensions.Logging;
using System;
using System.IO;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
namespace ImageCatalog_2;
public partial class AvaloniaMainWindow : Window
{
+ private const string ApiLoginKey = "ApiTest.Login";
+ private const string ApiPasswordKey = "ApiTest.Password";
+
private readonly DataModel _model;
+ private readonly IRaceUploadCommunicationClient _apiClient;
+ private readonly ParametriSetup _parametriSetup;
+ private readonly ILogger _logger;
private bool _isDarkTheme = false;
- public AvaloniaMainWindow(DataModel model)
+ public AvaloniaMainWindow(
+ DataModel model,
+ IRaceUploadCommunicationClient apiClient,
+ ParametriSetup parametriSetup,
+ ILogger logger)
{
InitializeComponent();
_model = model;
+ _apiClient = apiClient;
+ _parametriSetup = parametriSetup;
+ _logger = logger;
DataContext = _model;
// Provide Avalonia dispatcher so DataModel can marshal UI updates
@@ -104,6 +127,8 @@ public partial class AvaloniaMainWindow : Window
if (e.PropertyName == nameof(_model.LogoFile))
UpdateLogoPreview(_model.LogoFile);
};
+
+ LoadApiTestCredentials();
}
private void ToggleTheme_Click(object? sender, RoutedEventArgs e)
@@ -148,4 +173,205 @@ public partial class AvaloniaMainWindow : Window
try { preview.Source = new Avalonia.Media.Imaging.Bitmap(path); }
catch { preview.Source = null; }
}
+
+ private void LoadApiTestCredentials()
+ {
+ var loginBox = this.FindControl("ApiLoginTextBox");
+ var passwordBox = this.FindControl("ApiPasswordTextBox");
+ if (loginBox is null || passwordBox is null)
+ {
+ return;
+ }
+
+ loginBox.Text = _parametriSetup.LeggiParametroString(ApiLoginKey);
+ passwordBox.Text = _parametriSetup.LeggiParametroString(ApiPasswordKey);
+ }
+
+ private void SaveApiTestCredentials()
+ {
+ var loginBox = this.FindControl("ApiLoginTextBox");
+ var passwordBox = this.FindControl("ApiPasswordTextBox");
+ if (loginBox is null || passwordBox is null)
+ {
+ return;
+ }
+
+ _parametriSetup.AggiornaParametro(ApiLoginKey, loginBox.Text ?? string.Empty);
+ _parametriSetup.AggiornaParametro(ApiPasswordKey, passwordBox.Text ?? string.Empty);
+ _parametriSetup.SalvaParametriSetup();
+ }
+
+ private async void ApiTestLoginAndGetRaces_Click(object? sender, RoutedEventArgs e)
+ {
+ var loginBox = this.FindControl("ApiLoginTextBox");
+ var passwordBox = this.FindControl("ApiPasswordTextBox");
+ var outputBox = this.FindControl("ApiOutputTextBox");
+ var statusBlock = this.FindControl("ApiStatusTextBlock");
+ var testButton = this.FindControl("ApiTestButton");
+
+ if (loginBox is null || passwordBox is null || outputBox is null || statusBlock is null || testButton is null)
+ {
+ return;
+ }
+
+ var login = loginBox.Text?.Trim() ?? string.Empty;
+ var password = passwordBox.Text ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
+ {
+ statusBlock.Text = "Inserisci login e password.";
+ return;
+ }
+
+ testButton.IsEnabled = false;
+ statusBlock.Text = "Esecuzione test...";
+ outputBox.Text = string.Empty;
+
+ try
+ {
+ _logger.LogDebug("Starting API test request from Avalonia tab for user '{User}'.", login);
+ SaveApiTestCredentials();
+
+ var loginResponse = await _apiClient.LoginAdminAsync(
+ new AdminLoginRequest
+ {
+ Login = login,
+ Password = password,
+ Command = "check",
+ },
+ CancellationToken.None);
+
+ var searchResponse = await _apiClient.ExecuteGaraCommandAsync(
+ new Dictionary
+ {
+ ["cmd"] = "search",
+ ["pageNumber"] = "1",
+ },
+ CancellationToken.None);
+
+ _logger.LogDebug(
+ "API test completed requests. LoginStatus={LoginStatusCode}, SearchStatus={SearchStatusCode}",
+ (int)loginResponse.StatusCode,
+ (int)searchResponse.StatusCode);
+
+ var extracted = ExtractTopRaceLines(searchResponse.Body, 3);
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"Login HTTP: {(int)loginResponse.StatusCode} {loginResponse.StatusCode}");
+ sb.AppendLine($"Search HTTP: {(int)searchResponse.StatusCode} {searchResponse.StatusCode}");
+ sb.AppendLine();
+
+ if (extracted.Count > 0)
+ {
+ sb.AppendLine("Prime 3 righe gare (estrazione semplice):");
+ for (var i = 0; i < extracted.Count; i++)
+ {
+ sb.AppendLine($"{i + 1}. {extracted[i]}");
+ }
+ }
+ else
+ {
+ sb.AppendLine("Nessuna riga gara riconosciuta in modo affidabile. Mostro anteprima raw:");
+ sb.AppendLine();
+ sb.AppendLine(Truncate(CollapseWhitespace(searchResponse.Body), 1500));
+ }
+
+ outputBox.Text = sb.ToString();
+ statusBlock.Text = "Test completato.";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "API test failed in Avalonia tab.");
+ _logger.LogDebug("API test exception details: {ExceptionDetails}", ex.ToString());
+ outputBox.Text = ex.ToString();
+ statusBlock.Text = "Errore durante il test.";
+ }
+ finally
+ {
+ testButton.IsEnabled = true;
+ }
+ }
+
+ private static List ExtractTopRaceLines(string html, int take)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ {
+ return new List();
+ }
+
+ var rowMatches = Regex.Matches(html, "]*>(.*?)
", RegexOptions.IgnoreCase | RegexOptions.Singleline);
+ var lines = new List();
+
+ foreach (Match row in rowMatches)
+ {
+ var cells = Regex.Matches(row.Groups[1].Value, "]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline)
+ .Select(m => CollapseWhitespace(WebUtility.HtmlDecode(StripTags(m.Groups[1].Value))))
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .ToArray();
+
+ if (cells.Length < 2)
+ {
+ continue;
+ }
+
+ var joined = string.Join(" | ", cells);
+ if (IsHeaderLike(joined))
+ {
+ continue;
+ }
+
+ lines.Add(joined);
+ if (lines.Count >= take)
+ {
+ break;
+ }
+ }
+
+ if (lines.Count > 0)
+ {
+ return lines;
+ }
+
+ var textRows = Regex.Matches(html, "(?is)]*>(.*?)")
+ .Select(m => CollapseWhitespace(WebUtility.HtmlDecode(StripTags(m.Groups[1].Value))))
+ .Where(s => s.Length > 8)
+ .Take(take)
+ .ToList();
+
+ return textRows;
+ }
+
+ private static bool IsHeaderLike(string text)
+ {
+ var lower = text.ToLowerInvariant();
+ return lower.Contains("descrizione")
+ || lower.Contains("data")
+ || lower.Contains("stato")
+ || lower.Contains("azioni")
+ || lower.Contains("cerca");
+ }
+
+ private static string StripTags(string value)
+ {
+ return Regex.Replace(value, "<[^>]+>", " ", RegexOptions.Singleline);
+ }
+
+ private static string CollapseWhitespace(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return string.Empty;
+ }
+
+ return Regex.Replace(value, "\\s+", " ").Trim();
+ }
+
+ private static string Truncate(string value, int max)
+ {
+ if (value.Length <= max)
+ {
+ return value;
+ }
+
+ return value.Substring(0, max) + "...";
+ }
}
diff --git a/imagecatalog/ImageCatalog 2.csproj b/imagecatalog/ImageCatalog 2.csproj
index be90557..36ac938 100644
--- a/imagecatalog/ImageCatalog 2.csproj
+++ b/imagecatalog/ImageCatalog 2.csproj
@@ -46,6 +46,7 @@
+
diff --git a/imagecatalog/Program.cs b/imagecatalog/Program.cs
index bb5d519..f3d9b9d 100644
--- a/imagecatalog/Program.cs
+++ b/imagecatalog/Program.cs
@@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
+using Catalog.Communication.DependencyInjection;
using ImageCatalog;
using ImageCatalog_2.Services;
using MaddoShared;
@@ -59,7 +60,7 @@ static class Program
}
#endif
- public static IServiceProvider ServiceProvider { get; private set; }
+ public static IServiceProvider ServiceProvider { get; private set; } = default!;
public static Avalonia.AppBuilder BuildAvaloniaApp()
=> Avalonia.AppBuilder.Configure()
@@ -172,6 +173,17 @@ static class Program
services.AddSingleton(new ParametriSetup(userPrefsPath));
services.AddSingleton();
+ services.AddCatalogCommunication(options =>
+ {
+ options.BaseUri = new Uri("https://www.regalamiunsorriso.it/");
+ options.AdminPageBasePath = "admin/pg_RUS";
+ options.RequestTimeout = TimeSpan.FromSeconds(30);
+ options.RetryCount = 2;
+ options.RetryBaseDelay = TimeSpan.FromMilliseconds(250);
+ });
+
+ services.AddTransient();
+
#if WINDOWS
services.AddTransient();
services.AddTransient();
@@ -199,7 +211,7 @@ public static class ConsoleLoggerExtensions
}
public sealed class CustomLoggingFormatter : ConsoleFormatter, IDisposable
{
- private readonly IDisposable _optionsReloadToken;
+ private readonly IDisposable? _optionsReloadToken;
private ConsoleFormatterOptions _formatterOptions;
public CustomLoggingFormatter(IOptionsMonitor options)
// Case insensitive
@@ -214,6 +226,11 @@ public sealed class CustomLoggingFormatter : ConsoleFormatter, IDisposable
IExternalScopeProvider? scopeProvider,
TextWriter? textWriter)
{
+ if (textWriter is null)
+ {
+ return;
+ }
+
string? message =
logEntry.Formatter?.Invoke(
logEntry.State, logEntry.Exception);
@@ -223,7 +240,20 @@ public sealed class CustomLoggingFormatter : ConsoleFormatter, IDisposable
return;
}
- textWriter.WriteLine($"{message}");
+ var timestamp = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
+ var level = logEntry.LogLevel.ToString().ToUpperInvariant();
+ var category = logEntry.Category ?? "App";
+
+ var line = $"{timestamp} [{level}] {category}: {message}";
+ textWriter.WriteLine(line);
+ System.Diagnostics.Debug.WriteLine(line);
+
+ if (logEntry.Exception is not null)
+ {
+ var exceptionText = logEntry.Exception.ToString();
+ textWriter.WriteLine(exceptionText);
+ System.Diagnostics.Debug.WriteLine(exceptionText);
+ }
}
public void Dispose() => _optionsReloadToken?.Dispose();
}
\ No newline at end of file