feat: Add race upload functionality and file transfer endpoints
- Implemented IRaceUploadCommunicationClient with methods for saving races, creating race points, indexing race points, retrieving race details, and uploading files to the receiver. - Added ReceiveFilePath option to CatalogCommunicationOptions for file transfer configuration. - Enhanced CatalogCommunicationServiceCollectionExtensions to validate ReceiveFilePath. - Developed RaceUploadCommunicationClient to handle race-related API interactions, including saving race data and uploading processed images. - Updated API documentation to reflect new race upload and file transfer endpoints. - Modified Avalonia UI to support race creation and processed image uploads, including new input fields and buttons. - Introduced RaceSaveRequest and ReceiveFileUploadRequest models for structured data handling.
This commit is contained in:
parent
4a0973b681
commit
15b1da4371
11 changed files with 675 additions and 97 deletions
|
|
@ -14,6 +14,16 @@ public interface IRaceUploadCommunicationClient
|
||||||
|
|
||||||
Task<UploadFileResponse?> UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default);
|
Task<UploadFileResponse?> UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> SaveRaceAsync(RaceSaveRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> CreateRacePointsAsync(long raceId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> IndexRacePointAsync(long pointId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> GetRaceDetailAsync(long raceId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<RawEndpointResponse> UploadFileToReceiverAsync(ReceiveFileUploadRequest request, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<RawEndpointResponse> ExecuteGaraCommandAsync(IReadOnlyDictionary<string, string?> formFields, 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> ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ public sealed class CatalogCommunicationOptions
|
||||||
|
|
||||||
public string AdminPageBasePath { get; set; } = "admin/pg";
|
public string AdminPageBasePath { get; set; } = "admin/pg";
|
||||||
|
|
||||||
|
public string ReceiveFilePath { get; set; } = "ReceiveFile.abl";
|
||||||
|
|
||||||
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
public int RetryCount { get; set; } = 2;
|
public int RetryCount { get; set; } = 2;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public static class CatalogCommunicationServiceCollectionExtensions
|
||||||
.Configure(configure)
|
.Configure(configure)
|
||||||
.Validate(o => o.BaseUri is not null, "CatalogCommunicationOptions.BaseUri is required.")
|
.Validate(o => o.BaseUri is not null, "CatalogCommunicationOptions.BaseUri is required.")
|
||||||
.Validate(o => !string.IsNullOrWhiteSpace(o.AdminPageBasePath), "AdminPageBasePath is required.")
|
.Validate(o => !string.IsNullOrWhiteSpace(o.AdminPageBasePath), "AdminPageBasePath is required.")
|
||||||
|
.Validate(o => !string.IsNullOrWhiteSpace(o.ReceiveFilePath), "ReceiveFilePath is required.")
|
||||||
.Validate(o => o.RequestTimeout > TimeSpan.Zero, "RequestTimeout must be greater than zero.")
|
.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.RetryCount >= 0 && o.RetryCount <= 10, "RetryCount must be between 0 and 10.")
|
||||||
.Validate(o => o.RetryBaseDelay > TimeSpan.Zero, "RetryBaseDelay must be greater than zero.");
|
.Validate(o => o.RetryBaseDelay > TimeSpan.Zero, "RetryBaseDelay must be greater than zero.");
|
||||||
|
|
|
||||||
26
Catalog.Communication/Models/RaceSaveRequest.cs
Normal file
26
Catalog.Communication/Models/RaceSaveRequest.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
namespace Catalog.Communication.Models;
|
||||||
|
|
||||||
|
public sealed class RaceSaveRequest
|
||||||
|
{
|
||||||
|
public long IdGara { get; init; }
|
||||||
|
|
||||||
|
public required string Description { get; init; }
|
||||||
|
|
||||||
|
public required DateOnly StartDate { get; init; }
|
||||||
|
|
||||||
|
public DateOnly? EndDate { get; init; }
|
||||||
|
|
||||||
|
public required long TipoGaraId { get; init; }
|
||||||
|
|
||||||
|
public int EventoInLinea { get; init; }
|
||||||
|
|
||||||
|
public int TipoIndicizzazione { get; init; }
|
||||||
|
|
||||||
|
public int FreeEvent { get; init; }
|
||||||
|
|
||||||
|
public string? PathBase { get; init; }
|
||||||
|
|
||||||
|
public string? Localita { get; init; }
|
||||||
|
|
||||||
|
public long? CodGara { get; init; }
|
||||||
|
}
|
||||||
16
Catalog.Communication/Models/ReceiveFileUploadRequest.cs
Normal file
16
Catalog.Communication/Models/ReceiveFileUploadRequest.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace Catalog.Communication.Models;
|
||||||
|
|
||||||
|
public sealed class ReceiveFileUploadRequest
|
||||||
|
{
|
||||||
|
public required string FileName { get; init; }
|
||||||
|
|
||||||
|
public required Stream FileStream { get; init; }
|
||||||
|
|
||||||
|
public required string DestinationPath { get; init; }
|
||||||
|
|
||||||
|
public bool OverwriteRemoteFile { get; init; }
|
||||||
|
|
||||||
|
public int? BufferSize { get; init; }
|
||||||
|
|
||||||
|
public string? ContentType { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Globalization;
|
||||||
using Catalog.Communication.Abstractions;
|
using Catalog.Communication.Abstractions;
|
||||||
using Catalog.Communication.Models;
|
using Catalog.Communication.Models;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
@ -123,6 +124,97 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> SaveRaceAsync(RaceSaveRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var formFields = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["cmd"] = "asq",
|
||||||
|
["act"] = "save",
|
||||||
|
["id_gara"] = request.IdGara.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["descrizione"] = request.Description,
|
||||||
|
["dataGaraInizio"] = request.StartDate.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture),
|
||||||
|
["dataGaraFine"] = (request.EndDate ?? request.StartDate).ToString("dd/MM/yyyy", CultureInfo.InvariantCulture),
|
||||||
|
["id_tipoGara"] = request.TipoGaraId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["flgEventoInLinea"] = request.EventoInLinea.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["flgTipoIndex"] = request.TipoIndicizzazione.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["flgFree"] = request.FreeEvent.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["pathBase"] = request.PathBase,
|
||||||
|
["localita"] = request.Localita,
|
||||||
|
["codGara"] = request.CodGara?.ToString(CultureInfo.InvariantCulture),
|
||||||
|
};
|
||||||
|
|
||||||
|
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-save", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> CreateRacePointsAsync(long raceId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var formFields = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["cmd"] = "creaPuntiFoto",
|
||||||
|
["id_gara"] = raceId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
};
|
||||||
|
|
||||||
|
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-create-points", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> IndexRacePointAsync(long pointId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var formFields = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["cmd"] = "indexFoto",
|
||||||
|
["id_puntoFotoIdx"] = pointId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
};
|
||||||
|
|
||||||
|
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-index-point", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<RawEndpointResponse> GetRaceDetailAsync(long raceId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var formFields = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["cmd"] = "search",
|
||||||
|
["id_gara"] = raceId.ToString(CultureInfo.InvariantCulture),
|
||||||
|
};
|
||||||
|
|
||||||
|
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-detail", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RawEndpointResponse> UploadFileToReceiverAsync(ReceiveFileUploadRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.FileName);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(request.DestinationPath);
|
||||||
|
ArgumentNullException.ThrowIfNull(request.FileStream);
|
||||||
|
|
||||||
|
var payload = await ReadAllBytesAsync(request.FileStream, cancellationToken).ConfigureAwait(false);
|
||||||
|
var query = new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["name"] = request.FileName,
|
||||||
|
["path"] = request.DestinationPath,
|
||||||
|
["overwriteRemoteFile"] = request.OverwriteRemoteFile ? "true" : "false",
|
||||||
|
["bs"] = request.BufferSize?.ToString(CultureInfo.InvariantCulture),
|
||||||
|
};
|
||||||
|
|
||||||
|
var path = AppendQuery(GetReceiveFilePath(), query);
|
||||||
|
|
||||||
|
return await ExecuteWithResilienceAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var byteContent = new ByteArrayContent(payload);
|
||||||
|
byteContent.Headers.ContentType = new MediaTypeHeaderValue(request.ContentType ?? "application/octet-stream");
|
||||||
|
|
||||||
|
return new HttpRequestMessage(HttpMethod.Post, path)
|
||||||
|
{
|
||||||
|
Content = byteContent,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
ToRawResponseAsync,
|
||||||
|
"receiver-upload",
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<RawEndpointResponse> ExecuteGaraCommandAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default)
|
public Task<RawEndpointResponse> ExecuteGaraCommandAsync(IReadOnlyDictionary<string, string?> formFields, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-command", cancellationToken);
|
return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-command", cancellationToken);
|
||||||
|
|
@ -503,4 +595,15 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie
|
||||||
var basePath = _options.Value.AdminPageBasePath.Trim('/');
|
var basePath = _options.Value.AdminPageBasePath.Trim('/');
|
||||||
return $"{basePath}/{pageName}.abl";
|
return $"{basePath}/{pageName}.abl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetReceiveFilePath()
|
||||||
|
{
|
||||||
|
var value = _options.Value.ReceiveFilePath;
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return "ReceiveFile.abl";
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,8 @@ Example shape:
|
||||||
## Race command API (`/admin/pg/Gara.abl`)
|
## Race command API (`/admin/pg/Gara.abl`)
|
||||||
|
|
||||||
Confirmed custom commands in `GaraSvlt`:
|
Confirmed custom commands in `GaraSvlt`:
|
||||||
|
- `asq` + `act=save` (generic save used by admin UI toolbar)
|
||||||
|
- `ni` (new record flow before save)
|
||||||
- `addPuntoFoto`
|
- `addPuntoFoto`
|
||||||
- `delPuntoFoto`
|
- `delPuntoFoto`
|
||||||
- `modPuntoFoto`
|
- `modPuntoFoto`
|
||||||
|
|
@ -192,12 +194,44 @@ Important parameters by command:
|
||||||
- `id_puntoFoto` and `id_puntoFotoIdx` for point selection
|
- `id_puntoFoto` and `id_puntoFotoIdx` for point selection
|
||||||
- point fields such as `descrizionePuntoFoto`, `pathRelativoFoto`, `tipoPuntoFoto`
|
- point fields such as `descrizionePuntoFoto`, `pathRelativoFoto`, `tipoPuntoFoto`
|
||||||
|
|
||||||
|
Race save payload fields (from `admin/pg_RUS/gara.jsp` + `_V4/_js/_bean.js`):
|
||||||
|
- mandatory in practice: `descrizione`, `dataGaraInizio`, `id_tipoGara`
|
||||||
|
- commonly sent: `dataGaraFine`, `flgEventoInLinea`, `flgTipoIndex`, `pathBase`, `flgFree`, `localita`
|
||||||
|
- command envelope for save: `cmd=asq`, `act=save`
|
||||||
|
|
||||||
|
Confirmed fixed-flag value sets from UI templates:
|
||||||
|
- `flgEventoInLinea`: `0` (Non In Linea), `1` (Stand By), `2` (In Linea)
|
||||||
|
- `flgTipoIndex`: `0`, `1`
|
||||||
|
- `flgFree`: `0` (No), `1` (SI)
|
||||||
|
|
||||||
|
Server-side normalization behavior:
|
||||||
|
- `Gara.save()` appends trailing `/` to `pathBase` if missing.
|
||||||
|
- if `pathBase` is empty and `dataGaraInizio` is set, server auto-derives `pathBase` as `<year>/<id_gara>/`.
|
||||||
|
- `PuntoFoto.prepareSave()` appends trailing `/` to `pathRelativoFoto` if missing.
|
||||||
|
|
||||||
CSV flow coupling:
|
CSV flow coupling:
|
||||||
1. `cmd=saveFile` uploads to `tmp/` and returns `fileName`
|
1. `cmd=saveFile` uploads to `tmp/` and returns `fileName`
|
||||||
2. UI stores it into hidden field `fileNameOnServer_1`
|
2. UI stores it into hidden field `fileNameOnServer_1`
|
||||||
3. `cmd=salvaFileCsv` copies `DOCBASE/tmp/<fileNameOnServer_1>` to `DOCBASE/admin/csv/<id_gara>.csv`
|
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
|
4. `cmd=indexCsvPisa` reads `admin/csv/<id_gara>.csv` via `Gara.getImpCsvFileName()` and updates matching photos
|
||||||
|
|
||||||
|
## Processed photo transfer channel (`/ReceiveFile.abl`)
|
||||||
|
|
||||||
|
The original 3-piano -> WWW photo push does not use `Gara.abl` multipart for each processed photo.
|
||||||
|
It uses `UploadFile` against a dedicated receiver servlet:
|
||||||
|
- `POST /ReceiveFile.abl`
|
||||||
|
- servlet class: `com.ablia.servlet.ReceiveFileSvlt`
|
||||||
|
- typically unauthenticated (`isSecureServlet=false` in decompiled source)
|
||||||
|
|
||||||
|
Observed request shape:
|
||||||
|
- query params: `name`, `path`, `overwriteRemoteFile`, `bs`
|
||||||
|
- request body: raw file bytes stream
|
||||||
|
|
||||||
|
Usage in workflow:
|
||||||
|
- destination path is computed per punto-foto on remote side (`puntoFotoR.getPathCompletoFoto()`)
|
||||||
|
- processed image and thumbnail (`tn_<filename>`) are both transferred
|
||||||
|
- after transfer, photo flags are updated/indexed via DB-backed commands (`indexFoto` / CSV/indexing flows)
|
||||||
|
|
||||||
## Image retrieval behavior
|
## Image retrieval behavior
|
||||||
|
|
||||||
`/foto/*` (`GetFileTnSvlt`):
|
`/foto/*` (`GetFileTnSvlt`):
|
||||||
|
|
@ -223,8 +257,8 @@ For robust clients, validate both:
|
||||||
## Practical automation sequence
|
## Practical automation sequence
|
||||||
|
|
||||||
1. Login on `/admin/menu/Menu4.abl` with `cmdIU=check`, store cookies.
|
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.
|
2. Create race on `/admin/pg_RUS/Gara.abl` with `cmd=asq`, `act=save` and race fields.
|
||||||
3. Upload images with `cmd=loadImg`.
|
3. Create and/or manage punti foto (`addPuntoFoto`, `creaPuntiFoto`, `modPuntoFoto`).
|
||||||
4. (Optional) upload CSV with `cmd=saveFile`.
|
4. Transfer processed files with `/ReceiveFile.abl` to remote race/punto paths.
|
||||||
5. Finalize CSV placement with `cmd=salvaFileCsv` and run `cmd=indexCsvPisa`.
|
5. Trigger indexing (`indexFoto` per punto, or CSV via `saveFile` + `salvaFileCsv` + `indexCsvPisa`).
|
||||||
6. Read thumbnails from `/foto/*` and originals from `/fotoOriginali/*`.
|
6. Read thumbnails from `/foto/*` and originals from `/fotoOriginali/*`.
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ tags:
|
||||||
description: Admin photo/type/log endpoints (command-driven)
|
description: Admin photo/type/log endpoints (command-driven)
|
||||||
- name: Media
|
- name: Media
|
||||||
description: Thumbnail/original photo retrieval
|
description: Thumbnail/original photo retrieval
|
||||||
|
- name: FileTransfer
|
||||||
|
description: Low-level raw file receiver used by legacy race photo export flow
|
||||||
- name: Public
|
- name: Public
|
||||||
description: Public-facing login/user/photo endpoints mapped in web.xml
|
description: Public-facing login/user/photo endpoints mapped in web.xml
|
||||||
|
|
||||||
|
|
@ -172,6 +174,19 @@ paths:
|
||||||
descrizionePuntoFoto: "Start"
|
descrizionePuntoFoto: "Start"
|
||||||
pathRelativoFoto: "start/"
|
pathRelativoFoto: "start/"
|
||||||
tipoPuntoFoto: "A"
|
tipoPuntoFoto: "A"
|
||||||
|
saveRace:
|
||||||
|
value:
|
||||||
|
cmd: asq
|
||||||
|
act: save
|
||||||
|
id_gara: 0
|
||||||
|
descrizione: "Gara Demo 2026"
|
||||||
|
dataGaraInizio: "2026-05-10"
|
||||||
|
dataGaraFine: "2026-05-10"
|
||||||
|
id_tipoGara: 1
|
||||||
|
flgEventoInLinea: 0
|
||||||
|
flgTipoIndex: 1
|
||||||
|
pathBase: "2026/gara-demo/"
|
||||||
|
flgFree: 0
|
||||||
indexFoto:
|
indexFoto:
|
||||||
value:
|
value:
|
||||||
cmd: indexFoto
|
cmd: indexFoto
|
||||||
|
|
@ -232,6 +247,60 @@ paths:
|
||||||
htmlCommandResponse:
|
htmlCommandResponse:
|
||||||
value: "<html>...messaggi/bean rendering...</html>"
|
value: "<html>...messaggi/bean rendering...</html>"
|
||||||
|
|
||||||
|
/ReceiveFile.abl:
|
||||||
|
post:
|
||||||
|
tags: [FileTransfer]
|
||||||
|
summary: Raw file receiver used by legacy 3-piano to WWW transfer
|
||||||
|
description: |
|
||||||
|
Endpoint mapped to `ReceiveFileSvlt`.
|
||||||
|
The legacy client sends raw file bytes in request body and query parameters for destination metadata.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- `name`: destination filename
|
||||||
|
- `path`: absolute/target remote directory
|
||||||
|
- `overwriteRemoteFile`: `true|false`
|
||||||
|
- `bs`: optional buffer size hint
|
||||||
|
|
||||||
|
Note: decompiled servlet marks this endpoint as non-secure in code (`isSecureServlet=false`).
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: name
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: path
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: overwriteRemoteFile
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
- name: bs
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Receiver accepted the stream (response body is implementation-specific/plain text)
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
|
||||||
/admin/pg/Foto.abl:
|
/admin/pg/Foto.abl:
|
||||||
get:
|
get:
|
||||||
tags: [PhotoAdmin]
|
tags: [PhotoAdmin]
|
||||||
|
|
@ -546,6 +615,8 @@ components:
|
||||||
cmd:
|
cmd:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
|
- asq
|
||||||
|
- ni
|
||||||
- addPuntoFoto
|
- addPuntoFoto
|
||||||
- delPuntoFoto
|
- delPuntoFoto
|
||||||
- modPuntoFoto
|
- modPuntoFoto
|
||||||
|
|
@ -586,6 +657,20 @@ components:
|
||||||
dataGaraFine:
|
dataGaraFine:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
|
id_tipoGara:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
flgEventoInLinea:
|
||||||
|
type: integer
|
||||||
|
enum: [0, 1, 2]
|
||||||
|
flgTipoIndex:
|
||||||
|
type: integer
|
||||||
|
enum: [0, 1]
|
||||||
|
flgFree:
|
||||||
|
type: integer
|
||||||
|
enum: [0, 1]
|
||||||
|
localita:
|
||||||
|
type: string
|
||||||
|
|
||||||
GaraLoadImgMultipartRequest:
|
GaraLoadImgMultipartRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -270,12 +270,12 @@
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
<!-- Tab 7: API Test -->
|
<!-- Tab 7: Race Upload -->
|
||||||
<TabItem Header="API Test">
|
<TabItem Header="Race Upload">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="8" Spacing="8">
|
<StackPanel Margin="8" Spacing="8">
|
||||||
<TextBlock Text="Test comunicazione API (non distruttivo)" FontWeight="Bold" />
|
<TextBlock Text="Setup gara e upload foto processate" FontWeight="Bold" />
|
||||||
<TextBlock Text="Questa prova esegue login admin e una ricerca gare (cmd=search), poi mostra una sintesi delle prime 3 righe rilevate."
|
<TextBlock Text="Flusso: login admin, creazione gara, creazione punti foto, upload file processati da cartella destinazione locale, indicizzazione punti foto."
|
||||||
TextWrapping="Wrap" Opacity="0.8" />
|
TextWrapping="Wrap" Opacity="0.8" />
|
||||||
|
|
||||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
|
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
|
||||||
|
|
@ -286,8 +286,58 @@
|
||||||
<TextBox Grid.Row="1" Grid.Column="1" Name="ApiPasswordTextBox" PasswordChar="*" />
|
<TextBox Grid.Row="1" Grid.Column="1" Name="ApiPasswordTextBox" PasswordChar="*" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="Dati gara" FontWeight="Bold" Margin="0,4,0,0" />
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto,Auto,Auto" ColumnSpacing="8" RowSpacing="8">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Descrizione:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="0" Grid.Column="1" Name="ApiRaceDescriptionTextBox" Watermark="Nome gara" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="2" Text="Tipo Gara ID:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="0" Grid.Column="3" Name="ApiRaceTypeIdTextBox" Text="1" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Data Inizio:" VerticalAlignment="Center" />
|
||||||
|
<CalendarDatePicker Grid.Row="1" Grid.Column="1" Name="ApiRaceStartDatePicker" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="2" Text="Data Fine:" VerticalAlignment="Center" />
|
||||||
|
<CalendarDatePicker Grid.Row="1" Grid.Column="3" Name="ApiRaceEndDatePicker" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Text="Path Base Gara:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="2" Grid.Column="1" Name="ApiPathBaseTextBox" Watermark="2026/mia-gara/" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="2" Text="Localita:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="2" Grid.Column="3" Name="ApiLocalitaTextBox" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Text="Evento In Linea:" VerticalAlignment="Center" />
|
||||||
|
<ComboBox Grid.Row="3" Grid.Column="1" Name="ApiEventoInLineaComboBox" SelectedIndex="0">
|
||||||
|
<ComboBoxItem Content="0 - Non in linea" />
|
||||||
|
<ComboBoxItem Content="1 - Stand by" />
|
||||||
|
<ComboBoxItem Content="2 - In linea" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="2" Text="Tipo Indicizzazione:" VerticalAlignment="Center" />
|
||||||
|
<ComboBox Grid.Row="3" Grid.Column="3" Name="ApiTipoIndexComboBox" SelectedIndex="1">
|
||||||
|
<ComboBoxItem Content="0" />
|
||||||
|
<ComboBoxItem Content="1" />
|
||||||
|
</ComboBox>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0" Text="Evento Omaggio:" VerticalAlignment="Center" />
|
||||||
|
<ComboBox Grid.Row="0" Grid.Column="1" Name="ApiFreeEventComboBox" SelectedIndex="0">
|
||||||
|
<ComboBoxItem Content="0 - No" />
|
||||||
|
<ComboBoxItem Content="1 - SI" />
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="2" Text="id_gara corrente:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="0" Grid.Column="3" Name="ApiRaceIdTextBox" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Text="Path remoto processate:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="3" Name="ApiRemoteProcessedBasePathTextBox"
|
||||||
|
Watermark="/percorso/remoto/foto-ridotte" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Name="ApiTestButton" Content="Test login + ultime 3 gare" Click="ApiTestLoginAndGetRaces_Click" />
|
<Button Name="ApiCreateRaceButton" Content="Crea nuova gara" Click="CreateRace_Click" />
|
||||||
|
<Button Name="ApiUploadButton" Content="Upload foto processate" Click="UploadProcessed_Click" />
|
||||||
<TextBlock Name="ApiStatusTextBlock" VerticalAlignment="Center" />
|
<TextBlock Name="ApiStatusTextBlock" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ public partial class AvaloniaMainWindow : Window
|
||||||
{
|
{
|
||||||
private const string ApiLoginKey = "ApiTest.Login";
|
private const string ApiLoginKey = "ApiTest.Login";
|
||||||
private const string ApiPasswordKey = "ApiTest.Password";
|
private const string ApiPasswordKey = "ApiTest.Password";
|
||||||
|
private const string ApiRaceTypeKey = "RaceUpload.TipoGaraId";
|
||||||
|
private const string ApiRacePathBaseKey = "RaceUpload.PathBase";
|
||||||
|
private const string ApiRemoteProcessedBaseKey = "RaceUpload.RemoteProcessedBasePath";
|
||||||
|
private const string ApiRaceOnlineFlagKey = "RaceUpload.FlgEventoInLinea";
|
||||||
|
private const string ApiRaceIndexFlagKey = "RaceUpload.FlgTipoIndex";
|
||||||
|
private const string ApiRaceFreeFlagKey = "RaceUpload.FlgFree";
|
||||||
|
private const string ApiLastRaceIdKey = "RaceUpload.LastRaceId";
|
||||||
|
|
||||||
private readonly DataModel _model;
|
private readonly DataModel _model;
|
||||||
private readonly IRaceUploadCommunicationClient _apiClient;
|
private readonly IRaceUploadCommunicationClient _apiClient;
|
||||||
|
|
@ -129,6 +136,7 @@ public partial class AvaloniaMainWindow : Window
|
||||||
};
|
};
|
||||||
|
|
||||||
LoadApiTestCredentials();
|
LoadApiTestCredentials();
|
||||||
|
LoadRaceUploadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleTheme_Click(object? sender, RoutedEventArgs e)
|
private void ToggleTheme_Click(object? sender, RoutedEventArgs e)
|
||||||
|
|
@ -201,177 +209,419 @@ public partial class AvaloniaMainWindow : Window
|
||||||
_parametriSetup.SalvaParametriSetup();
|
_parametriSetup.SalvaParametriSetup();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ApiTestLoginAndGetRaces_Click(object? sender, RoutedEventArgs e)
|
private void LoadRaceUploadSettings()
|
||||||
|
{
|
||||||
|
var raceTypeBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRaceTypeIdTextBox");
|
||||||
|
var pathBaseBox = this.FindControl<Avalonia.Controls.TextBox>("ApiPathBaseTextBox");
|
||||||
|
var remoteBaseBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRemoteProcessedBasePathTextBox");
|
||||||
|
var onlineBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiEventoInLineaComboBox");
|
||||||
|
var indexBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiTipoIndexComboBox");
|
||||||
|
var freeBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiFreeEventComboBox");
|
||||||
|
var raceIdBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRaceIdTextBox");
|
||||||
|
|
||||||
|
if (raceTypeBox is not null)
|
||||||
|
{
|
||||||
|
raceTypeBox.Text = _parametriSetup.LeggiParametroString(ApiRaceTypeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathBaseBox is not null)
|
||||||
|
{
|
||||||
|
pathBaseBox.Text = _parametriSetup.LeggiParametroString(ApiRacePathBaseKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteBaseBox is not null)
|
||||||
|
{
|
||||||
|
remoteBaseBox.Text = _parametriSetup.LeggiParametroString(ApiRemoteProcessedBaseKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetComboSelection(onlineBox, _parametriSetup.LeggiParametro(ApiRaceOnlineFlagKey, 0));
|
||||||
|
SetComboSelection(indexBox, _parametriSetup.LeggiParametro(ApiRaceIndexFlagKey, 1));
|
||||||
|
SetComboSelection(freeBox, _parametriSetup.LeggiParametro(ApiRaceFreeFlagKey, 0));
|
||||||
|
|
||||||
|
if (raceIdBox is not null)
|
||||||
|
{
|
||||||
|
raceIdBox.Text = _parametriSetup.LeggiParametroString(ApiLastRaceIdKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveRaceUploadSettings()
|
||||||
|
{
|
||||||
|
var raceTypeBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRaceTypeIdTextBox");
|
||||||
|
var pathBaseBox = this.FindControl<Avalonia.Controls.TextBox>("ApiPathBaseTextBox");
|
||||||
|
var remoteBaseBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRemoteProcessedBasePathTextBox");
|
||||||
|
var onlineBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiEventoInLineaComboBox");
|
||||||
|
var indexBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiTipoIndexComboBox");
|
||||||
|
var freeBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiFreeEventComboBox");
|
||||||
|
var raceIdBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRaceIdTextBox");
|
||||||
|
|
||||||
|
_parametriSetup.AggiornaParametro(ApiRaceTypeKey, raceTypeBox?.Text ?? string.Empty);
|
||||||
|
_parametriSetup.AggiornaParametro(ApiRacePathBaseKey, pathBaseBox?.Text ?? string.Empty);
|
||||||
|
_parametriSetup.AggiornaParametro(ApiRemoteProcessedBaseKey, remoteBaseBox?.Text ?? string.Empty);
|
||||||
|
_parametriSetup.AggiornaParametro(ApiRaceOnlineFlagKey, GetComboSelection(onlineBox).ToString());
|
||||||
|
_parametriSetup.AggiornaParametro(ApiRaceIndexFlagKey, GetComboSelection(indexBox).ToString());
|
||||||
|
_parametriSetup.AggiornaParametro(ApiRaceFreeFlagKey, GetComboSelection(freeBox).ToString());
|
||||||
|
_parametriSetup.AggiornaParametro(ApiLastRaceIdKey, raceIdBox?.Text ?? string.Empty);
|
||||||
|
_parametriSetup.SalvaParametriSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetComboSelection(Avalonia.Controls.ComboBox? comboBox, int value)
|
||||||
|
{
|
||||||
|
if (comboBox is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
comboBox.SelectedIndex = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetComboSelection(Avalonia.Controls.ComboBox? comboBox)
|
||||||
|
{
|
||||||
|
return comboBox?.SelectedIndex is int index && index >= 0 ? index : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void CreateRace_Click(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var loginBox = this.FindControl<Avalonia.Controls.TextBox>("ApiLoginTextBox");
|
var loginBox = this.FindControl<Avalonia.Controls.TextBox>("ApiLoginTextBox");
|
||||||
var passwordBox = this.FindControl<Avalonia.Controls.TextBox>("ApiPasswordTextBox");
|
var passwordBox = this.FindControl<Avalonia.Controls.TextBox>("ApiPasswordTextBox");
|
||||||
|
var raceTypeBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRaceTypeIdTextBox");
|
||||||
|
var raceDescriptionBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRaceDescriptionTextBox");
|
||||||
|
var raceStartPicker = this.FindControl<CalendarDatePicker>("ApiRaceStartDatePicker");
|
||||||
|
var raceEndPicker = this.FindControl<CalendarDatePicker>("ApiRaceEndDatePicker");
|
||||||
|
var pathBaseBox = this.FindControl<Avalonia.Controls.TextBox>("ApiPathBaseTextBox");
|
||||||
|
var localitaBox = this.FindControl<Avalonia.Controls.TextBox>("ApiLocalitaTextBox");
|
||||||
|
var raceIdBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRaceIdTextBox");
|
||||||
|
var eventoInLineaBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiEventoInLineaComboBox");
|
||||||
|
var tipoIndexBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiTipoIndexComboBox");
|
||||||
|
var freeBox = this.FindControl<Avalonia.Controls.ComboBox>("ApiFreeEventComboBox");
|
||||||
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("ApiOutputTextBox");
|
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("ApiOutputTextBox");
|
||||||
var statusBlock = this.FindControl<TextBlock>("ApiStatusTextBlock");
|
var statusBlock = this.FindControl<TextBlock>("ApiStatusTextBlock");
|
||||||
var testButton = this.FindControl<Avalonia.Controls.Button>("ApiTestButton");
|
var createButton = this.FindControl<Avalonia.Controls.Button>("ApiCreateRaceButton");
|
||||||
|
var uploadButton = this.FindControl<Avalonia.Controls.Button>("ApiUploadButton");
|
||||||
|
|
||||||
if (loginBox is null || passwordBox is null || outputBox is null || statusBlock is null || testButton is null)
|
if (loginBox is null || passwordBox is null || raceTypeBox is null || raceDescriptionBox is null || raceStartPicker is null ||
|
||||||
|
raceEndPicker is null || pathBaseBox is null || localitaBox is null || raceIdBox is null ||
|
||||||
|
eventoInLineaBox is null || tipoIndexBox is null || freeBox is null ||
|
||||||
|
outputBox is null || statusBlock is null || createButton is null || uploadButton is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var login = loginBox.Text?.Trim() ?? string.Empty;
|
var login = loginBox.Text?.Trim() ?? string.Empty;
|
||||||
var password = passwordBox.Text ?? string.Empty;
|
var password = passwordBox.Text ?? string.Empty;
|
||||||
|
var descriptionRaw = raceDescriptionBox.Text?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password) || string.IsNullOrWhiteSpace(descriptionRaw))
|
||||||
|
{
|
||||||
|
statusBlock.Text = "Inserisci login, password e descrizione gara.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!long.TryParse(raceTypeBox.Text?.Trim(), out var tipoGaraId) || tipoGaraId <= 0)
|
||||||
|
{
|
||||||
|
statusBlock.Text = "Tipo gara non valido.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!raceStartPicker.SelectedDate.HasValue)
|
||||||
|
{
|
||||||
|
statusBlock.Text = "Seleziona la data di inizio gara.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createButton.IsEnabled = false;
|
||||||
|
uploadButton.IsEnabled = false;
|
||||||
|
statusBlock.Text = "Creazione gara in corso...";
|
||||||
|
outputBox.Text = string.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startDate = DateOnly.FromDateTime(raceStartPicker.SelectedDate.Value.Date);
|
||||||
|
var endDate = raceEndPicker.SelectedDate.HasValue
|
||||||
|
? DateOnly.FromDateTime(raceEndPicker.SelectedDate.Value.Date)
|
||||||
|
: startDate;
|
||||||
|
var sanitizedDescription = SanitizeRaceDescription(descriptionRaw);
|
||||||
|
|
||||||
|
SaveApiTestCredentials();
|
||||||
|
SaveRaceUploadSettings();
|
||||||
|
|
||||||
|
var loginResponse = await LoginAsync(login, password).ConfigureAwait(true);
|
||||||
|
|
||||||
|
var saveResponse = await _apiClient.SaveRaceAsync(
|
||||||
|
new RaceSaveRequest
|
||||||
|
{
|
||||||
|
IdGara = 0,
|
||||||
|
Description = sanitizedDescription,
|
||||||
|
StartDate = startDate,
|
||||||
|
EndDate = endDate,
|
||||||
|
TipoGaraId = tipoGaraId,
|
||||||
|
EventoInLinea = GetComboSelection(eventoInLineaBox),
|
||||||
|
TipoIndicizzazione = GetComboSelection(tipoIndexBox),
|
||||||
|
FreeEvent = GetComboSelection(freeBox),
|
||||||
|
PathBase = pathBaseBox.Text?.Trim(),
|
||||||
|
Localita = localitaBox.Text?.Trim(),
|
||||||
|
},
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var raceId = ExtractRaceId(saveResponse.Body);
|
||||||
|
if (raceId <= 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Impossibile ricavare id_gara dalla risposta di salvataggio.");
|
||||||
|
}
|
||||||
|
|
||||||
|
raceIdBox.Text = raceId.ToString();
|
||||||
|
SaveRaceUploadSettings();
|
||||||
|
|
||||||
|
var createPointsResponse = await _apiClient.CreateRacePointsAsync(raceId, CancellationToken.None);
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"Login HTTP: {(int)loginResponse.StatusCode} {loginResponse.StatusCode}");
|
||||||
|
sb.AppendLine($"Save Gara HTTP: {(int)saveResponse.StatusCode} {saveResponse.StatusCode}");
|
||||||
|
sb.AppendLine($"Crea Punti HTTP: {(int)createPointsResponse.StatusCode} {createPointsResponse.StatusCode}");
|
||||||
|
sb.AppendLine($"id_gara: {raceId}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Gara creata e avvio creazione punti richiesto.");
|
||||||
|
|
||||||
|
outputBox.Text = sb.ToString();
|
||||||
|
statusBlock.Text = "Gara creata.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Race creation failed in Avalonia tab.");
|
||||||
|
outputBox.Text = ex.ToString();
|
||||||
|
statusBlock.Text = "Errore durante la creazione gara.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
createButton.IsEnabled = true;
|
||||||
|
uploadButton.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void UploadProcessed_Click(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var loginBox = this.FindControl<Avalonia.Controls.TextBox>("ApiLoginTextBox");
|
||||||
|
var passwordBox = this.FindControl<Avalonia.Controls.TextBox>("ApiPasswordTextBox");
|
||||||
|
var raceIdBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRaceIdTextBox");
|
||||||
|
var pathBaseBox = this.FindControl<Avalonia.Controls.TextBox>("ApiPathBaseTextBox");
|
||||||
|
var remoteBaseBox = this.FindControl<Avalonia.Controls.TextBox>("ApiRemoteProcessedBasePathTextBox");
|
||||||
|
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("ApiOutputTextBox");
|
||||||
|
var statusBlock = this.FindControl<TextBlock>("ApiStatusTextBlock");
|
||||||
|
var createButton = this.FindControl<Avalonia.Controls.Button>("ApiCreateRaceButton");
|
||||||
|
var uploadButton = this.FindControl<Avalonia.Controls.Button>("ApiUploadButton");
|
||||||
|
|
||||||
|
if (loginBox is null || passwordBox is null || raceIdBox is null || pathBaseBox is null || remoteBaseBox is null ||
|
||||||
|
outputBox is null || statusBlock is null || createButton is null || uploadButton is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var login = loginBox.Text?.Trim() ?? string.Empty;
|
||||||
|
var password = passwordBox.Text ?? string.Empty;
|
||||||
|
var racePathBase = pathBaseBox.Text?.Trim() ?? string.Empty;
|
||||||
|
var remoteProcessedBase = remoteBaseBox.Text?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (!long.TryParse(raceIdBox.Text?.Trim(), out var raceId) || raceId <= 0)
|
||||||
|
{
|
||||||
|
statusBlock.Text = "id_gara non valido.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
|
||||||
{
|
{
|
||||||
statusBlock.Text = "Inserisci login e password.";
|
statusBlock.Text = "Inserisci login e password.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
testButton.IsEnabled = false;
|
if (string.IsNullOrWhiteSpace(_model.DestinationPath) || !Directory.Exists(_model.DestinationPath))
|
||||||
statusBlock.Text = "Esecuzione test...";
|
{
|
||||||
outputBox.Text = string.Empty;
|
statusBlock.Text = "Cartella destinazione locale non valida.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(remoteProcessedBase))
|
||||||
|
{
|
||||||
|
statusBlock.Text = "Inserisci il path base remoto per le foto processate.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createButton.IsEnabled = false;
|
||||||
|
uploadButton.IsEnabled = false;
|
||||||
|
statusBlock.Text = "Upload foto processate in corso...";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Starting API test request from Avalonia tab for user '{User}'.", login);
|
|
||||||
SaveApiTestCredentials();
|
SaveApiTestCredentials();
|
||||||
|
SaveRaceUploadSettings();
|
||||||
|
await LoginAsync(login, password).ConfigureAwait(true);
|
||||||
|
|
||||||
var loginResponse = await _apiClient.LoginAdminAsync(
|
var files = Directory
|
||||||
new AdminLoginRequest
|
.EnumerateFiles(_model.DestinationPath, "*.*", SearchOption.AllDirectories)
|
||||||
{
|
.Where(IsSupportedImage)
|
||||||
Login = login,
|
.ToList();
|
||||||
Password = password,
|
|
||||||
Command = "check",
|
|
||||||
},
|
|
||||||
CancellationToken.None);
|
|
||||||
|
|
||||||
var searchResponse = await _apiClient.ExecuteGaraCommandAsync(
|
if (files.Count == 0)
|
||||||
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):");
|
statusBlock.Text = "Nessuna immagine trovata in destinazione.";
|
||||||
for (var i = 0; i < extracted.Count; i++)
|
outputBox.Text = "Nessun file processato da inviare.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploaded = 0;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"File da inviare: {files.Count}");
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var relativePath = Path.GetRelativePath(_model.DestinationPath, file);
|
||||||
|
var relativeDir = Path.GetDirectoryName(relativePath) ?? string.Empty;
|
||||||
|
var remotePath = CombineRemotePath(remoteProcessedBase, racePathBase, relativeDir);
|
||||||
|
|
||||||
|
await using var stream = File.OpenRead(file);
|
||||||
|
await _apiClient.UploadFileToReceiverAsync(
|
||||||
|
new ReceiveFileUploadRequest
|
||||||
|
{
|
||||||
|
FileName = Path.GetFileName(file),
|
||||||
|
FileStream = stream,
|
||||||
|
DestinationPath = remotePath,
|
||||||
|
OverwriteRemoteFile = true,
|
||||||
|
},
|
||||||
|
CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
uploaded++;
|
||||||
|
if (uploaded % 20 == 0 || uploaded == files.Count)
|
||||||
{
|
{
|
||||||
sb.AppendLine($"{i + 1}. {extracted[i]}");
|
statusBlock.Text = $"Upload foto: {uploaded}/{files.Count}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
sb.AppendLine($"Upload completato: {uploaded}/{files.Count}");
|
||||||
|
statusBlock.Text = "Creazione punti foto e indicizzazione in corso...";
|
||||||
|
|
||||||
|
await _apiClient.CreateRacePointsAsync(raceId, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
var pointIds = await LoadPointIdsWithRetryAsync(raceId, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
foreach (var pointId in pointIds)
|
||||||
{
|
{
|
||||||
sb.AppendLine("Nessuna riga gara riconosciuta in modo affidabile. Mostro anteprima raw:");
|
await _apiClient.IndexRacePointAsync(pointId, CancellationToken.None).ConfigureAwait(true);
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine(Truncate(CollapseWhitespace(searchResponse.Body), 1500));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sb.AppendLine($"Punti foto indicizzati: {pointIds.Count}");
|
||||||
outputBox.Text = sb.ToString();
|
outputBox.Text = sb.ToString();
|
||||||
statusBlock.Text = "Test completato.";
|
statusBlock.Text = "Upload e indicizzazione completati.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "API test failed in Avalonia tab.");
|
_logger.LogError(ex, "Upload flow failed in Avalonia tab.");
|
||||||
_logger.LogDebug("API test exception details: {ExceptionDetails}", ex.ToString());
|
|
||||||
outputBox.Text = ex.ToString();
|
outputBox.Text = ex.ToString();
|
||||||
statusBlock.Text = "Errore durante il test.";
|
statusBlock.Text = "Errore durante upload/indicizzazione.";
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
testButton.IsEnabled = true;
|
createButton.IsEnabled = true;
|
||||||
|
uploadButton.IsEnabled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> ExtractTopRaceLines(string html, int take)
|
private async Task<RawEndpointResponse> LoginAsync(string login, string password)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(html))
|
return await _apiClient.LoginAdminAsync(
|
||||||
{
|
new AdminLoginRequest
|
||||||
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;
|
Login = login,
|
||||||
|
Password = password,
|
||||||
|
Command = "check",
|
||||||
|
},
|
||||||
|
CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<long>> LoadPointIdsWithRetryAsync(long raceId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const int maxAttempts = 10;
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
||||||
|
{
|
||||||
|
var response = await _apiClient.GetRaceDetailAsync(raceId, cancellationToken).ConfigureAwait(false);
|
||||||
|
var ids = ExtractPointIds(response.Body);
|
||||||
|
|
||||||
|
if (ids.Count > 0)
|
||||||
|
{
|
||||||
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
var joined = string.Join(" | ", cells);
|
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
|
||||||
if (IsHeaderLike(joined))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.Add(joined);
|
|
||||||
if (lines.Count >= take)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lines.Count > 0)
|
return new List<long>();
|
||||||
{
|
}
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
var textRows = Regex.Matches(html, "(?is)<li[^>]*>(.*?)</li>")
|
private static List<long> ExtractPointIds(string html)
|
||||||
.Select(m => CollapseWhitespace(WebUtility.HtmlDecode(StripTags(m.Groups[1].Value))))
|
{
|
||||||
.Where(s => s.Length > 8)
|
var ids = Regex
|
||||||
.Take(take)
|
.Matches(html ?? string.Empty, @"indexFoto\((\d+)\)", RegexOptions.IgnoreCase)
|
||||||
|
.Select(m => long.TryParse(m.Groups[1].Value, out var value) ? value : 0L)
|
||||||
|
.Where(v => v > 0)
|
||||||
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return textRows;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsHeaderLike(string text)
|
private static string SanitizeRaceDescription(string value)
|
||||||
{
|
|
||||||
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))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Regex.Replace(value, "\\s+", " ").Trim();
|
var cleaned = Regex.Replace(value, "[^A-Za-z0-9 _-]", " ");
|
||||||
|
return Regex.Replace(cleaned, "\\s+", " ").Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Truncate(string value, int max)
|
private static string CombineRemotePath(string remoteBase, string racePathBase, string relativeDir)
|
||||||
{
|
{
|
||||||
if (value.Length <= max)
|
var segments = new[] { remoteBase, racePathBase, relativeDir }
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.Select(s => s!.Replace('\\', '/').Trim('/'));
|
||||||
|
|
||||||
|
var joined = string.Join('/', segments);
|
||||||
|
return string.IsNullOrWhiteSpace(joined) ? "/" : joined + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSupportedImage(string filePath)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(filePath);
|
||||||
|
if (string.IsNullOrWhiteSpace(extension))
|
||||||
{
|
{
|
||||||
return value;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.Substring(0, max) + "...";
|
return extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".png", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".bmp", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| extension.Equals(".gif", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ExtractRaceId(string html)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(html))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var inputMatch = Regex.Match(
|
||||||
|
html,
|
||||||
|
"id=\\\"id_gara\\\"[^>]*value=\\\"(?<id>\\d+)\\\"",
|
||||||
|
RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
if (inputMatch.Success && long.TryParse(inputMatch.Groups["id"].Value, out var idFromInput))
|
||||||
|
{
|
||||||
|
return idFromInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
var labelMatch = Regex.Match(html, "Descrizione \\(id: (?<id>\\d+)\\)", RegexOptions.IgnoreCase);
|
||||||
|
return labelMatch.Success && long.TryParse(labelMatch.Groups["id"].Value, out var idFromLabel)
|
||||||
|
? idFromLabel
|
||||||
|
: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ static class Program
|
||||||
{
|
{
|
||||||
options.BaseUri = new Uri("https://www.regalamiunsorriso.it/");
|
options.BaseUri = new Uri("https://www.regalamiunsorriso.it/");
|
||||||
options.AdminPageBasePath = "admin/pg_RUS";
|
options.AdminPageBasePath = "admin/pg_RUS";
|
||||||
|
options.ReceiveFilePath = "ReceiveFile.abl";
|
||||||
options.RequestTimeout = TimeSpan.FromSeconds(30);
|
options.RequestTimeout = TimeSpan.FromSeconds(30);
|
||||||
options.RetryCount = 2;
|
options.RetryCount = 2;
|
||||||
options.RetryBaseDelay = TimeSpan.FromMilliseconds(250);
|
options.RetryBaseDelay = TimeSpan.FromMilliseconds(250);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue