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:
MaddoScientisto 2026-02-28 16:54:08 +01:00
commit 15b1da4371
11 changed files with 675 additions and 97 deletions

View file

@ -270,12 +270,12 @@
</ScrollViewer>
</TabItem>
<!-- Tab 7: API Test -->
<TabItem Header="API Test">
<!-- Tab 7: Race Upload -->
<TabItem Header="Race Upload">
<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."
<TextBlock Text="Setup gara e upload foto processate" FontWeight="Bold" />
<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" />
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto" ColumnSpacing="8" RowSpacing="8">
@ -286,8 +286,58 @@
<TextBox Grid.Row="1" Grid.Column="1" Name="ApiPasswordTextBox" PasswordChar="*" />
</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">
<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" />
</StackPanel>

View file

@ -24,6 +24,13 @@ public partial class AvaloniaMainWindow : Window
{
private const string ApiLoginKey = "ApiTest.Login";
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 IRaceUploadCommunicationClient _apiClient;
@ -129,6 +136,7 @@ public partial class AvaloniaMainWindow : Window
};
LoadApiTestCredentials();
LoadRaceUploadSettings();
}
private void ToggleTheme_Click(object? sender, RoutedEventArgs e)
@ -201,177 +209,419 @@ public partial class AvaloniaMainWindow : Window
_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 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 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;
}
var login = loginBox.Text?.Trim() ?? 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))
{
statusBlock.Text = "Inserisci login e password.";
return;
}
testButton.IsEnabled = false;
statusBlock.Text = "Esecuzione test...";
outputBox.Text = string.Empty;
if (string.IsNullOrWhiteSpace(_model.DestinationPath) || !Directory.Exists(_model.DestinationPath))
{
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
{
_logger.LogDebug("Starting API test request from Avalonia tab for user '{User}'.", login);
SaveApiTestCredentials();
SaveRaceUploadSettings();
await LoginAsync(login, password).ConfigureAwait(true);
var loginResponse = await _apiClient.LoginAdminAsync(
new AdminLoginRequest
{
Login = login,
Password = password,
Command = "check",
},
CancellationToken.None);
var files = Directory
.EnumerateFiles(_model.DestinationPath, "*.*", SearchOption.AllDirectories)
.Where(IsSupportedImage)
.ToList();
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)
if (files.Count == 0)
{
sb.AppendLine("Prime 3 righe gare (estrazione semplice):");
for (var i = 0; i < extracted.Count; i++)
statusBlock.Text = "Nessuna immagine trovata in destinazione.";
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:");
sb.AppendLine();
sb.AppendLine(Truncate(CollapseWhitespace(searchResponse.Body), 1500));
await _apiClient.IndexRacePointAsync(pointId, CancellationToken.None).ConfigureAwait(true);
}
sb.AppendLine($"Punti foto indicizzati: {pointIds.Count}");
outputBox.Text = sb.ToString();
statusBlock.Text = "Test completato.";
statusBlock.Text = "Upload e indicizzazione completati.";
}
catch (Exception ex)
{
_logger.LogError(ex, "API test failed in Avalonia tab.");
_logger.LogDebug("API test exception details: {ExceptionDetails}", ex.ToString());
_logger.LogError(ex, "Upload flow failed in Avalonia tab.");
outputBox.Text = ex.ToString();
statusBlock.Text = "Errore durante il test.";
statusBlock.Text = "Errore durante upload/indicizzazione.";
}
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 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)
return await _apiClient.LoginAdminAsync(
new AdminLoginRequest
{
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);
if (IsHeaderLike(joined))
{
continue;
}
lines.Add(joined);
if (lines.Count >= take)
{
break;
}
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
if (lines.Count > 0)
{
return lines;
}
return new List<long>();
}
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)
private static List<long> ExtractPointIds(string html)
{
var ids = Regex
.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();
return textRows;
return ids;
}
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)
private static string SanitizeRaceDescription(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
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;
}
}

View file

@ -177,6 +177,7 @@ static class Program
{
options.BaseUri = new Uri("https://www.regalamiunsorriso.it/");
options.AdminPageBasePath = "admin/pg_RUS";
options.ReceiveFilePath = "ReceiveFile.abl";
options.RequestTimeout = TimeSpan.FromSeconds(30);
options.RetryCount = 2;
options.RetryBaseDelay = TimeSpan.FromMilliseconds(250);