Added avalonia integration and remote proof of concept
This commit is contained in:
parent
775080a178
commit
4a0973b681
23 changed files with 2043 additions and 6 deletions
90
.github/copilot-instructions.md
vendored
Normal file
90
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -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 (`<Nullable>enable</Nullable>`)
|
||||||
|
- Implicit usings enabled
|
||||||
|
- `ConfigureAwait(false)` on all `await` calls in library code
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
- Constructor injection throughout; loggers typed as `ILogger<T>`
|
||||||
|
- 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<Config> 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`)
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Catalog.Communication.Models;
|
||||||
|
|
||||||
|
namespace Catalog.Communication.Abstractions;
|
||||||
|
|
||||||
|
public interface IRaceUploadCommunicationClient
|
||||||
|
{
|
||||||
|
Task<RawEndpointResponse> LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> LogoutAdminAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<UploadImageResponse?> UploadRaceImageAsync(RaceImageUploadRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<UploadImageResponse?> RemoveRaceImageAsync(RaceImageRemoveRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<UploadFileResponse?> UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> ExecuteGaraCommandAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> ExecutePublicLogonAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> ExecuteUsersAsync(HttpMethod method, IReadOnlyDictionary<string, string?>? formFields = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> ExecuteFoto2Async(HttpMethod method, IReadOnlyDictionary<string, string?>? formFields = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<MediaFileResponse> DownloadThumbnailAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<MediaFileResponse> DownloadOriginalAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
17
Catalog.Communication/Catalog.Communication.csproj
Normal file
17
Catalog.Communication/Catalog.Communication.csproj
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<OutputType>Library</OutputType>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
14
Catalog.Communication/CatalogCommunicationOptions.cs
Normal file
14
Catalog.Communication/CatalogCommunicationOptions.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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<CatalogCommunicationOptions> configure)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(configure);
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddOptions<CatalogCommunicationOptions>()
|
||||||
|
.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<CookieContainer>();
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddHttpClient<IRaceUploadCommunicationClient, RaceUploadCommunicationClient>((sp, client) =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<CatalogCommunicationOptions>>().Value;
|
||||||
|
client.BaseAddress = options.BaseUri;
|
||||||
|
})
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||||
|
{
|
||||||
|
var cookieContainer = sp.GetRequiredService<CookieContainer>();
|
||||||
|
return new HttpClientHandler
|
||||||
|
{
|
||||||
|
UseCookies = true,
|
||||||
|
CookieContainer = cookieContainer,
|
||||||
|
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Catalog.Communication/Models/AdminLoginRequest.cs
Normal file
10
Catalog.Communication/Models/AdminLoginRequest.cs
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
8
Catalog.Communication/Models/AdminPhotoEndpoint.cs
Normal file
8
Catalog.Communication/Models/AdminPhotoEndpoint.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace Catalog.Communication.Models;
|
||||||
|
|
||||||
|
public enum AdminPhotoEndpoint
|
||||||
|
{
|
||||||
|
Foto = 0,
|
||||||
|
TipoGara = 1,
|
||||||
|
LogFoto = 2,
|
||||||
|
}
|
||||||
16
Catalog.Communication/Models/MediaFileResponse.cs
Normal file
16
Catalog.Communication/Models/MediaFileResponse.cs
Normal file
|
|
@ -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<string, IReadOnlyList<string>> Headers { get; init; }
|
||||||
|
}
|
||||||
16
Catalog.Communication/Models/RaceFileUploadRequest.cs
Normal file
16
Catalog.Communication/Models/RaceFileUploadRequest.cs
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
10
Catalog.Communication/Models/RaceImageRemoveRequest.cs
Normal file
10
Catalog.Communication/Models/RaceImageRemoveRequest.cs
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
18
Catalog.Communication/Models/RaceImageUploadRequest.cs
Normal file
18
Catalog.Communication/Models/RaceImageUploadRequest.cs
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
12
Catalog.Communication/Models/RawEndpointResponse.cs
Normal file
12
Catalog.Communication/Models/RawEndpointResponse.cs
Normal file
|
|
@ -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<string, IReadOnlyList<string>> Headers { get; init; }
|
||||||
|
}
|
||||||
18
Catalog.Communication/Models/UploadFileResponse.cs
Normal file
18
Catalog.Communication/Models/UploadFileResponse.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
15
Catalog.Communication/Models/UploadImageResponse.cs
Normal file
15
Catalog.Communication/Models/UploadImageResponse.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
506
Catalog.Communication/RaceUploadCommunicationClient.cs
Normal file
506
Catalog.Communication/RaceUploadCommunicationClient.cs
Normal file
|
|
@ -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<RaceUploadCommunicationClient> _logger;
|
||||||
|
private readonly IOptions<CatalogCommunicationOptions> _options;
|
||||||
|
|
||||||
|
public RaceUploadCommunicationClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IOptions<CatalogCommunicationOptions> options,
|
||||||
|
ILogger<RaceUploadCommunicationClient> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var formFields = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["login"] = request.Login,
|
||||||
|
["pwd"] = request.Password,
|
||||||
|
["cmdIU"] = request.Command,
|
||||||
|
};
|
||||||
|
|
||||||
|
return PostFormAsync(AdminMenuPath, formFields, "admin-login", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> LogoutAdminAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var formFields = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["cmdIU"] = "login",
|
||||||
|
};
|
||||||
|
|
||||||
|
return PostFormAsync(AdminMenuPath, formFields, "admin-logout", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<UploadImageResponse?> UploadRaceImageAsync(RaceImageUploadRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
return PostMultipartAndParseUploadAsync<UploadImageResponse>(
|
||||||
|
GetAdminPagePath("Gara"),
|
||||||
|
"gara-upload-image",
|
||||||
|
request.FileStream,
|
||||||
|
request.FileName,
|
||||||
|
request.FormFieldName,
|
||||||
|
request.ContentType,
|
||||||
|
static fields =>
|
||||||
|
{
|
||||||
|
fields["cmd"] = "loadImg";
|
||||||
|
},
|
||||||
|
new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["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<UploadImageResponse?> RemoveRaceImageAsync(RaceImageRemoveRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var fields = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["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<UploadImageResponse>(GetAdminPagePath("Gara"), fields, "gara-remove-image", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<UploadFileResponse?> UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
return PostMultipartAndParseUploadAsync<UploadFileResponse>(
|
||||||
|
GetAdminPagePath("Gara"),
|
||||||
|
"gara-upload-file",
|
||||||
|
request.FileStream,
|
||||||
|
request.FileName,
|
||||||
|
request.FormFieldName,
|
||||||
|
request.ContentType,
|
||||||
|
static fields =>
|
||||||
|
{
|
||||||
|
fields["cmd"] = "saveFile";
|
||||||
|
},
|
||||||
|
new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["codFile"] = request.CodFile?.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
["id"] = request.Id?.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> ExecuteGaraCommandAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-command", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary<string, string?> 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<RawEndpointResponse> ExecutePublicLogonAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return PostFormAsync(PublicLogonPath, formFields, "public-logon", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> ExecuteUsersAsync(HttpMethod method, IReadOnlyDictionary<string, string?>? formFields = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return SendCommandAsync(method, UsersPath, formFields, "public-users", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> ExecuteFoto2Async(HttpMethod method, IReadOnlyDictionary<string, string?>? formFields = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return SendCommandAsync(method, Foto2Path, formFields, "public-foto2", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MediaFileResponse> DownloadThumbnailAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(filename);
|
||||||
|
return DownloadFileAsync(ThumbnailPath, filename, idFoto, "media-thumbnail", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MediaFileResponse> DownloadOriginalAsync(string filename, long? idFoto = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(filename);
|
||||||
|
return DownloadFileAsync(OriginalPath, filename, idFoto, "media-original", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<RawEndpointResponse> SendCommandAsync(HttpMethod method, string path, IReadOnlyDictionary<string, string?>? 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<string, string?>(), operationName, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotSupportedException($"Only GET and POST are supported. Requested method: {method}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<RawEndpointResponse> GetAsync(string relativePath, string operationName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return ExecuteWithResilienceAsync(
|
||||||
|
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
||||||
|
ToRawResponseAsync,
|
||||||
|
operationName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<RawEndpointResponse> PostFormAsync(string relativePath, IReadOnlyDictionary<string, string?> formFields, string operationName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return ExecuteWithResilienceAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, relativePath)
|
||||||
|
{
|
||||||
|
Content = BuildFormContent(formFields),
|
||||||
|
};
|
||||||
|
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
ToRawResponseAsync,
|
||||||
|
operationName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<TUpload?> PostFormAndParseUploadAsync<TUpload>(string relativePath, IReadOnlyDictionary<string, string?> 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<TUpload>(raw.Body);
|
||||||
|
},
|
||||||
|
operationName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TUpload?> PostMultipartAndParseUploadAsync<TUpload>(
|
||||||
|
string relativePath,
|
||||||
|
string operationName,
|
||||||
|
Stream fileStream,
|
||||||
|
string fileName,
|
||||||
|
string formFieldName,
|
||||||
|
string? contentType,
|
||||||
|
Action<Dictionary<string, string?>> configureRequiredFields,
|
||||||
|
IReadOnlyDictionary<string, string?> 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<string, string?>();
|
||||||
|
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<TUpload>(raw.Body);
|
||||||
|
},
|
||||||
|
operationName,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<MediaFileResponse> 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<TResponse> ExecuteWithResilienceAsync<TResponse>(
|
||||||
|
Func<HttpRequestMessage> requestFactory,
|
||||||
|
Func<HttpResponseMessage, CancellationToken, Task<TResponse>> 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<string, string?> formFields)
|
||||||
|
{
|
||||||
|
var pairs = formFields
|
||||||
|
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
|
||||||
|
.Select(kvp => new KeyValuePair<string, string>(kvp.Key, kvp.Value!));
|
||||||
|
|
||||||
|
return new FormUrlEncodedContent(pairs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string AppendQuery(string path, IReadOnlyDictionary<string, string?>? 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<RawEndpointResponse> 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<string, IReadOnlyList<string>> BuildHeaders(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
var headers = new Dictionary<string, IReadOnlyList<string>>(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<TUpload>(string json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = JsonSerializer.Deserialize<List<TUpload>>(json, JsonOptions);
|
||||||
|
return items is { Count: > 0 } ? items[0] : default;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Catalog.sln
15
Catalog.sln
|
|
@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Tests", "MaddoS
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Benchmarks", "MaddoShared.Benchmarks\MaddoShared.Benchmarks.csproj", "{07499348-8C15-4DCC-8316-4AD121A43C38}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaddoShared.Benchmarks", "MaddoShared.Benchmarks\MaddoShared.Benchmarks.csproj", "{07499348-8C15-4DCC-8316-4AD121A43C38}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Catalog.Communication", "Catalog.Communication\Catalog.Communication.csproj", "{EF5D3B7E-F380-4976-A0A9-085FEA157F79}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{07499348-8C15-4DCC-8316-4AD121A43C38}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{AEBFE9E3-277C-4A7B-8448-145D1B11998B} = {A3D50937-74F6-4DC8-8D89-B534B484C0F9}
|
{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}
|
{59952BE8-20B4-4BF2-9367-705F41395265} = {5F0BEF23-B1EA-4100-A772-DC455D40B1C1}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
|
|
||||||
230
docs/api-race-upload-spec.md
Normal file
230
docs/api-race-upload-spec.md
Normal file
|
|
@ -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/<generated-file>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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/<fileName>`)
|
||||||
|
|
||||||
|
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/<fileNameOnServer_1>` to `DOCBASE/admin/csv/<id_gara>.csv`
|
||||||
|
4. `cmd=indexCsvPisa` reads `admin/csv/<id_gara>.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-<id>.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/*`.
|
||||||
679
docs/openapi-race-upload.yaml
Normal file
679
docs/openapi-race-upload.yaml
Normal file
|
|
@ -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: "<html>...LOGIN_OK...</html>"
|
||||||
|
'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: "<html>...messaggi/bean rendering...</html>"
|
||||||
|
|
||||||
|
/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-<id>.<ext>`.
|
||||||
|
- 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'
|
||||||
|
|
@ -12,8 +12,7 @@ public partial class AvaloniaApp : Avalonia.Application
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
var model = Program.ServiceProvider.GetRequiredService<DataModel>();
|
desktop.MainWindow = Program.ServiceProvider.GetRequiredService<AvaloniaMainWindow>();
|
||||||
desktop.MainWindow = new AvaloniaMainWindow(model);
|
|
||||||
}
|
}
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,37 @@
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- Tab 7: API Test -->
|
||||||
|
<TabItem Header="API Test">
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Margin="8" Spacing="8">
|
||||||
|
<TextBlock Text="Test comunicazione API (non distruttivo)" FontWeight="Bold" />
|
||||||
|
<TextBlock Text="Questa prova esegue login admin e una ricerca gare (cmd=search), poi mostra una sintesi delle prime 3 righe rilevate."
|
||||||
|
TextWrapping="Wrap" Opacity="0.8" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Login:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="0" Grid.Column="1" Name="ApiLoginTextBox" Watermark="admin user" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Password:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="1" Grid.Column="1" Name="ApiPasswordTextBox" PasswordChar="*" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Name="ApiTestButton" Content="Test login + ultime 3 gare" Click="ApiTestLoginAndGetRaces_Click" />
|
||||||
|
<TextBlock Name="ApiStatusTextBlock" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="Output" FontWeight="Bold" Margin="0,4,0,0" />
|
||||||
|
<TextBox Name="ApiOutputTextBox"
|
||||||
|
IsReadOnly="True"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MinHeight="240" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
</TabControl>
|
</TabControl>
|
||||||
|
|
||||||
<!-- Right: Controls and live info -->
|
<!-- Right: Controls and live info -->
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,43 @@ using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Catalog.Communication.Abstractions;
|
||||||
|
using Catalog.Communication.Models;
|
||||||
|
using ImageCatalog;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
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;
|
namespace ImageCatalog_2;
|
||||||
|
|
||||||
public partial class AvaloniaMainWindow : Window
|
public partial class AvaloniaMainWindow : Window
|
||||||
{
|
{
|
||||||
|
private const string ApiLoginKey = "ApiTest.Login";
|
||||||
|
private const string ApiPasswordKey = "ApiTest.Password";
|
||||||
|
|
||||||
private readonly DataModel _model;
|
private readonly DataModel _model;
|
||||||
|
private readonly IRaceUploadCommunicationClient _apiClient;
|
||||||
|
private readonly ParametriSetup _parametriSetup;
|
||||||
|
private readonly ILogger<AvaloniaMainWindow> _logger;
|
||||||
private bool _isDarkTheme = false;
|
private bool _isDarkTheme = false;
|
||||||
|
|
||||||
public AvaloniaMainWindow(DataModel model)
|
public AvaloniaMainWindow(
|
||||||
|
DataModel model,
|
||||||
|
IRaceUploadCommunicationClient apiClient,
|
||||||
|
ParametriSetup parametriSetup,
|
||||||
|
ILogger<AvaloniaMainWindow> logger)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_model = model;
|
_model = model;
|
||||||
|
_apiClient = apiClient;
|
||||||
|
_parametriSetup = parametriSetup;
|
||||||
|
_logger = logger;
|
||||||
DataContext = _model;
|
DataContext = _model;
|
||||||
|
|
||||||
// Provide Avalonia dispatcher so DataModel can marshal UI updates
|
// Provide Avalonia dispatcher so DataModel can marshal UI updates
|
||||||
|
|
@ -104,6 +127,8 @@ public partial class AvaloniaMainWindow : Window
|
||||||
if (e.PropertyName == nameof(_model.LogoFile))
|
if (e.PropertyName == nameof(_model.LogoFile))
|
||||||
UpdateLogoPreview(_model.LogoFile);
|
UpdateLogoPreview(_model.LogoFile);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
LoadApiTestCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleTheme_Click(object? sender, RoutedEventArgs e)
|
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); }
|
try { preview.Source = new Avalonia.Media.Imaging.Bitmap(path); }
|
||||||
catch { preview.Source = null; }
|
catch { preview.Source = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LoadApiTestCredentials()
|
||||||
|
{
|
||||||
|
var loginBox = this.FindControl<Avalonia.Controls.TextBox>("ApiLoginTextBox");
|
||||||
|
var passwordBox = this.FindControl<Avalonia.Controls.TextBox>("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<Avalonia.Controls.TextBox>("ApiLoginTextBox");
|
||||||
|
var passwordBox = this.FindControl<Avalonia.Controls.TextBox>("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<Avalonia.Controls.TextBox>("ApiLoginTextBox");
|
||||||
|
var passwordBox = this.FindControl<Avalonia.Controls.TextBox>("ApiPasswordTextBox");
|
||||||
|
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("ApiOutputTextBox");
|
||||||
|
var statusBlock = this.FindControl<TextBlock>("ApiStatusTextBlock");
|
||||||
|
var testButton = this.FindControl<Avalonia.Controls.Button>("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<string, string?>
|
||||||
|
{
|
||||||
|
["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<string> ExtractTopRaceLines(string html, int take)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(html))
|
||||||
|
{
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowMatches = Regex.Matches(html, "<tr[^>]*>(.*?)</tr>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
|
var lines = new List<string>();
|
||||||
|
|
||||||
|
foreach (Match row in rowMatches)
|
||||||
|
{
|
||||||
|
var cells = Regex.Matches(row.Groups[1].Value, "<t[dh][^>]*>(.*?)</t[dh]>", 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)<li[^>]*>(.*?)</li>")
|
||||||
|
.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) + "...";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Catalog.Communication\Catalog.Communication.csproj" />
|
||||||
<ProjectReference Include="..\MaddoShared\MaddoShared.csproj" />
|
<ProjectReference Include="..\MaddoShared\MaddoShared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using Catalog.Communication.DependencyInjection;
|
||||||
using ImageCatalog;
|
using ImageCatalog;
|
||||||
using ImageCatalog_2.Services;
|
using ImageCatalog_2.Services;
|
||||||
using MaddoShared;
|
using MaddoShared;
|
||||||
|
|
@ -59,7 +60,7 @@ static class Program
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
public static IServiceProvider ServiceProvider { get; private set; }
|
public static IServiceProvider ServiceProvider { get; private set; } = default!;
|
||||||
|
|
||||||
public static Avalonia.AppBuilder BuildAvaloniaApp()
|
public static Avalonia.AppBuilder BuildAvaloniaApp()
|
||||||
=> Avalonia.AppBuilder.Configure<AvaloniaApp>()
|
=> Avalonia.AppBuilder.Configure<AvaloniaApp>()
|
||||||
|
|
@ -172,6 +173,17 @@ static class Program
|
||||||
services.AddSingleton(new ParametriSetup(userPrefsPath));
|
services.AddSingleton(new ParametriSetup(userPrefsPath));
|
||||||
services.AddSingleton<PicSettings>();
|
services.AddSingleton<PicSettings>();
|
||||||
|
|
||||||
|
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<AvaloniaMainWindow>();
|
||||||
|
|
||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
services.AddTransient<MainForm>();
|
services.AddTransient<MainForm>();
|
||||||
services.AddTransient<ImageCatalog_2.MainWindow>();
|
services.AddTransient<ImageCatalog_2.MainWindow>();
|
||||||
|
|
@ -199,7 +211,7 @@ public static class ConsoleLoggerExtensions
|
||||||
}
|
}
|
||||||
public sealed class CustomLoggingFormatter : ConsoleFormatter, IDisposable
|
public sealed class CustomLoggingFormatter : ConsoleFormatter, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IDisposable _optionsReloadToken;
|
private readonly IDisposable? _optionsReloadToken;
|
||||||
private ConsoleFormatterOptions _formatterOptions;
|
private ConsoleFormatterOptions _formatterOptions;
|
||||||
public CustomLoggingFormatter(IOptionsMonitor<ConsoleFormatterOptions> options)
|
public CustomLoggingFormatter(IOptionsMonitor<ConsoleFormatterOptions> options)
|
||||||
// Case insensitive
|
// Case insensitive
|
||||||
|
|
@ -214,6 +226,11 @@ public sealed class CustomLoggingFormatter : ConsoleFormatter, IDisposable
|
||||||
IExternalScopeProvider? scopeProvider,
|
IExternalScopeProvider? scopeProvider,
|
||||||
TextWriter? textWriter)
|
TextWriter? textWriter)
|
||||||
{
|
{
|
||||||
|
if (textWriter is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
string? message =
|
string? message =
|
||||||
logEntry.Formatter?.Invoke(
|
logEntry.Formatter?.Invoke(
|
||||||
logEntry.State, logEntry.Exception);
|
logEntry.State, logEntry.Exception);
|
||||||
|
|
@ -223,7 +240,20 @@ public sealed class CustomLoggingFormatter : ConsoleFormatter, IDisposable
|
||||||
return;
|
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();
|
public void Dispose() => _optionsReloadToken?.Dispose();
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue