diff --git a/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs b/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs index 799c766..26d1b76 100644 --- a/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs +++ b/Catalog.Communication/Abstractions/IRaceUploadCommunicationClient.cs @@ -14,6 +14,16 @@ public interface IRaceUploadCommunicationClient Task UploadRaceFileAsync(RaceFileUploadRequest request, CancellationToken cancellationToken = default); + Task SaveRaceAsync(RaceSaveRequest request, CancellationToken cancellationToken = default); + + Task CreateRacePointsAsync(long raceId, CancellationToken cancellationToken = default); + + Task IndexRacePointAsync(long pointId, CancellationToken cancellationToken = default); + + Task GetRaceDetailAsync(long raceId, CancellationToken cancellationToken = default); + + Task UploadFileToReceiverAsync(ReceiveFileUploadRequest request, CancellationToken cancellationToken = default); + Task ExecuteGaraCommandAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default); Task ExecuteAdminPhotoCommandAsync(AdminPhotoEndpoint endpoint, IReadOnlyDictionary formFields, CancellationToken cancellationToken = default); diff --git a/Catalog.Communication/CatalogCommunicationOptions.cs b/Catalog.Communication/CatalogCommunicationOptions.cs index ac83eea..6044d2e 100644 --- a/Catalog.Communication/CatalogCommunicationOptions.cs +++ b/Catalog.Communication/CatalogCommunicationOptions.cs @@ -6,6 +6,8 @@ public sealed class CatalogCommunicationOptions public string AdminPageBasePath { get; set; } = "admin/pg"; + public string ReceiveFilePath { get; set; } = "ReceiveFile.abl"; + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30); public int RetryCount { get; set; } = 2; diff --git a/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs b/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs index 0090ca5..231242d 100644 --- a/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs +++ b/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class CatalogCommunicationServiceCollectionExtensions .Configure(configure) .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.ReceiveFilePath), "ReceiveFilePath 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."); diff --git a/Catalog.Communication/Models/RaceSaveRequest.cs b/Catalog.Communication/Models/RaceSaveRequest.cs new file mode 100644 index 0000000..88293a6 --- /dev/null +++ b/Catalog.Communication/Models/RaceSaveRequest.cs @@ -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; } +} diff --git a/Catalog.Communication/Models/ReceiveFileUploadRequest.cs b/Catalog.Communication/Models/ReceiveFileUploadRequest.cs new file mode 100644 index 0000000..07b0b85 --- /dev/null +++ b/Catalog.Communication/Models/ReceiveFileUploadRequest.cs @@ -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; } +} diff --git a/Catalog.Communication/RaceUploadCommunicationClient.cs b/Catalog.Communication/RaceUploadCommunicationClient.cs index a5f9d49..fc3987a 100644 --- a/Catalog.Communication/RaceUploadCommunicationClient.cs +++ b/Catalog.Communication/RaceUploadCommunicationClient.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Globalization; using Catalog.Communication.Abstractions; using Catalog.Communication.Models; using Microsoft.Extensions.Logging; @@ -123,6 +124,97 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie cancellationToken); } + public Task SaveRaceAsync(RaceSaveRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var formFields = new Dictionary + { + ["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 CreateRacePointsAsync(long raceId, CancellationToken cancellationToken = default) + { + var formFields = new Dictionary + { + ["cmd"] = "creaPuntiFoto", + ["id_gara"] = raceId.ToString(CultureInfo.InvariantCulture), + }; + + return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-create-points", cancellationToken); + } + + public Task IndexRacePointAsync(long pointId, CancellationToken cancellationToken = default) + { + var formFields = new Dictionary + { + ["cmd"] = "indexFoto", + ["id_puntoFotoIdx"] = pointId.ToString(CultureInfo.InvariantCulture), + }; + + return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-index-point", cancellationToken); + } + + public Task GetRaceDetailAsync(long raceId, CancellationToken cancellationToken = default) + { + var formFields = new Dictionary + { + ["cmd"] = "search", + ["id_gara"] = raceId.ToString(CultureInfo.InvariantCulture), + }; + + return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-detail", cancellationToken); + } + + public async Task 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 + { + ["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 ExecuteGaraCommandAsync(IReadOnlyDictionary formFields, CancellationToken cancellationToken = default) { return PostFormAsync(GetAdminPagePath("Gara"), formFields, "gara-command", cancellationToken); @@ -503,4 +595,15 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie var basePath = _options.Value.AdminPageBasePath.Trim('/'); return $"{basePath}/{pageName}.abl"; } + + private string GetReceiveFilePath() + { + var value = _options.Value.ReceiveFilePath; + if (string.IsNullOrWhiteSpace(value)) + { + return "ReceiveFile.abl"; + } + + return value.Trim(); + } } diff --git a/docs/api-race-upload-spec.md b/docs/api-race-upload-spec.md index 0748114..847cfd0 100644 --- a/docs/api-race-upload-spec.md +++ b/docs/api-race-upload-spec.md @@ -178,6 +178,8 @@ Example shape: ## Race command API (`/admin/pg/Gara.abl`) Confirmed custom commands in `GaraSvlt`: +- `asq` + `act=save` (generic save used by admin UI toolbar) +- `ni` (new record flow before save) - `addPuntoFoto` - `delPuntoFoto` - `modPuntoFoto` @@ -192,12 +194,44 @@ Important parameters by command: - `id_puntoFoto` and `id_puntoFotoIdx` for point selection - 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 `//`. +- `PuntoFoto.prepareSave()` appends trailing `/` to `pathRelativoFoto` if missing. + CSV flow coupling: 1. `cmd=saveFile` uploads to `tmp/` and returns `fileName` 2. UI stores it into hidden field `fileNameOnServer_1` 3. `cmd=salvaFileCsv` copies `DOCBASE/tmp/` to `DOCBASE/admin/csv/.csv` 4. `cmd=indexCsvPisa` reads `admin/csv/.csv` via `Gara.getImpCsvFileName()` and updates matching photos +## 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_`) are both transferred +- after transfer, photo flags are updated/indexed via DB-backed commands (`indexFoto` / CSV/indexing flows) + ## Image retrieval behavior `/foto/*` (`GetFileTnSvlt`): @@ -223,8 +257,8 @@ For robust clients, validate both: ## 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`. +2. Create race on `/admin/pg_RUS/Gara.abl` with `cmd=asq`, `act=save` and race fields. +3. Create and/or manage punti foto (`addPuntoFoto`, `creaPuntiFoto`, `modPuntoFoto`). +4. Transfer processed files with `/ReceiveFile.abl` to remote race/punto paths. +5. Trigger indexing (`indexFoto` per punto, or CSV via `saveFile` + `salvaFileCsv` + `indexCsvPisa`). 6. Read thumbnails from `/foto/*` and originals from `/fotoOriginali/*`. diff --git a/docs/openapi-race-upload.yaml b/docs/openapi-race-upload.yaml index 3266182..4c84198 100644 --- a/docs/openapi-race-upload.yaml +++ b/docs/openapi-race-upload.yaml @@ -34,6 +34,8 @@ tags: description: Admin photo/type/log endpoints (command-driven) - name: Media description: Thumbnail/original photo retrieval + - name: FileTransfer + description: Low-level raw file receiver used by legacy race photo export flow - name: Public description: Public-facing login/user/photo endpoints mapped in web.xml @@ -172,6 +174,19 @@ paths: descrizionePuntoFoto: "Start" pathRelativoFoto: "start/" 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: value: cmd: indexFoto @@ -232,6 +247,60 @@ paths: htmlCommandResponse: value: "...messaggi/bean rendering..." + /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: get: tags: [PhotoAdmin] @@ -546,6 +615,8 @@ components: cmd: type: string enum: + - asq + - ni - addPuntoFoto - delPuntoFoto - modPuntoFoto @@ -586,6 +657,20 @@ components: dataGaraFine: type: string 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: type: object diff --git a/imagecatalog/AvaloniaMainWindow.axaml b/imagecatalog/AvaloniaMainWindow.axaml index 6229707..dfc2baa 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml +++ b/imagecatalog/AvaloniaMainWindow.axaml @@ -270,12 +270,12 @@ - - + + - - + @@ -286,8 +286,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -