diff --git a/.github/instructions/repository-information.instructions.md b/.github/instructions/repository-information.instructions.md index 438d574..30e55b9 100644 --- a/.github/instructions/repository-information.instructions.md +++ b/.github/instructions/repository-information.instructions.md @@ -31,7 +31,7 @@ Key repository information: Current API surface highlights: -- Supported client endpoints include `/test`, `/languages`, `/search`, `/search-entries`, `/search-rdf`, `/search-definitions`, `/fluky-search`, `/entries`, `/senses`, and advanced search variants. +- Supported client endpoints include `/test`, `/languages`, `/search`, `/search-entries`, `/search-rdf`, `/search-by-definitions`, `/fluky-search`, `/entries`, `/senses`, and advanced search variants. - The `/me` endpoint has been removed and should not be described as supported. When answering questions about this repository, reference this file for the canonical project layout and repository-level details. diff --git a/.github/workflows/beta-package.yml b/.github/workflows/beta-package.yml index 567903d..77e1c1d 100644 --- a/.github/workflows/beta-package.yml +++ b/.github/workflows/beta-package.yml @@ -22,5 +22,15 @@ jobs: run: dotnet test ./source/Lexicala.NET.slnx --no-restore --verbosity normal - name: Pack run: dotnet pack ./source/Lexicala.NET/Lexicala.NET.csproj --configuration Release --no-build --version-suffix "beta.${{ github.run_number }}" --output ./artifacts + - name: Resolve package file + id: package + shell: pwsh + run: | + $versionPrefix = dotnet msbuild ./source/Lexicala.NET/Lexicala.NET.csproj -nologo -getProperty:VersionPrefix + $versionPrefix = $versionPrefix.Trim() + $version = "$versionPrefix-beta.${{ github.run_number }}" + "package_file=./artifacts/Lexicala.NET.$version.nupkg" >> $env:GITHUB_OUTPUT - name: Publish to NuGet - run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push "${{ steps.package.outputs.package_file }}" --api-key "$env:NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.github/workflows/build-on-push.yml b/.github/workflows/build-on-push.yml index 0774443..c848178 100644 --- a/.github/workflows/build-on-push.yml +++ b/.github/workflows/build-on-push.yml @@ -6,6 +6,10 @@ on: # don't run on master because we already have another action for that branches-ignore: [master] +permissions: + contents: read + packages: read + jobs: build: diff --git a/.github/workflows/build-test_pull-request.yml b/.github/workflows/build-test_pull-request.yml index 76de43d..2d56e1d 100644 --- a/.github/workflows/build-test_pull-request.yml +++ b/.github/workflows/build-test_pull-request.yml @@ -4,6 +4,11 @@ on: pull_request: branches: [ master ] paths-ignore: ['**.md', '.github/**'] + +permissions: + contents: read + packages: read + jobs: build: diff --git a/.github/workflows/package-main.yml b/.github/workflows/package-main.yml index 372ceaf..93e8aeb 100644 --- a/.github/workflows/package-main.yml +++ b/.github/workflows/package-main.yml @@ -5,6 +5,10 @@ on: branches: [ master ] paths-ignore: ['**.md', '.github/**'] +permissions: + contents: read + packages: read + jobs: build: @@ -24,5 +28,14 @@ jobs: run: dotnet test ./source/Lexicala.NET.slnx --no-restore --verbosity normal - name: Pack run: dotnet pack ./source/Lexicala.NET/Lexicala.NET.csproj --configuration Release --no-build --output ./artifacts + - name: Resolve package file + id: package + shell: pwsh + run: | + $version = dotnet msbuild ./source/Lexicala.NET/Lexicala.NET.csproj -nologo -getProperty:VersionPrefix + $version = $version.Trim() + "package_file=./artifacts/Lexicala.NET.$version.nupkg" >> $env:GITHUB_OUTPUT - name: Publish to NuGet - run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push "${{ steps.package.outputs.package_file }}" --api-key "$env:NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/README.md b/README.md index be958a1..dbcc86f 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,16 @@ Set `UseLiteEndpoints` to `true` if your subscription only allows Lite entry/sen - `/entries-lite/{entryId}` instead of `/entries/{entryId}` - `/senses-lite/{senseId}` instead of `/senses/{senseId}` +To restore missing translation details when using lite endpoints, use the translation-enriched methods: + +- `GetEntryWithTranslationsAsync(entryId, targetLanguage)` +- `GetSenseWithTranslationsAsync(senseId, targetLanguage)` + +These methods call translation endpoints to enrich missing sense and example translations. + ### 3. Register Services -In your `Program.cs` (for .NET 6+): +In your `Program.cs`: ```csharp using Lexicala.NET; @@ -238,7 +245,7 @@ The repository includes an ASP.NET Core minimal Web API demo host with Swagger U dotnet run ``` -3. Open Swagger UI in your browser: +4. Open Swagger UI in your browser: - HTTP: `http://localhost:5000/swagger` - HTTPS: `https://localhost:5001/swagger` @@ -252,7 +259,7 @@ Available endpoints: - `GET /search-entries` - Basic search with full entries - `GET /search-entries-lite` - Basic search with full entries in lite mode (`UseLiteEndpoints=true`) - `GET /search-rdf` - Basic search in RDF/JSON-LD format -- `GET /search-definitions` - Free-text search in definitions +- `GET /search-by-definitions` - Free-text search in definitions - `GET /fluky-search` - Random word discovery - `GET /entries/{entryId}` - Get dictionary entry by ID - `GET /entries-lite/{entryId}` - Get dictionary entry by ID in lite mode (`UseLiteEndpoints=true`) @@ -277,6 +284,9 @@ Missing endpoints compared to Rapid Api test console / Lexicala MCP tooling - th - `GET /semantic-categories` - `GET /subcategorizations` - `GET /synonyms` + +Implemented in this SDK (not exposed by the demo API host routes): + - `GET /translate-to` - `GET /translate-example` - `GET /translate-phrase` @@ -286,7 +296,7 @@ For React frontend development, CORS is enabled for: - `http://localhost:3000` - `http://localhost:5173` -## Sense Sprint Demo Game +## Demo Game A dedicated React + Vite frontend for a word guessing game is available at `source/Demo/sense-sprint-web`. @@ -303,12 +313,14 @@ The web demo currently includes two game modes. `Sense Sprint` is the lower-cost 3. Install dependencies and start the dev server: **PowerShell:** + ```powershell npm.cmd install npm.cmd run dev ``` **Bash / Command Prompt:** + ```bash npm install npm run dev @@ -355,27 +367,27 @@ The library validates and supports these commonly used API parameter values: The library implements the following Lexicala API endpoints: -**Utility Endpoints** +### Utility Endpoints - `/test` - Test API connectivity - `/languages` - Get available languages -**Search Endpoints** +### Search Endpoints - `/search` - Basic search - `/search-entries` - Search with full entries - `/search-entries-lite` - Search with full entries in lite mode (`UseLiteEndpoints=true`) - `/search-rdf` - Search in RDF/JSON-LD format -- `/search-definitions` - Free-text search in definitions +- `/search-by-definitions` - Free-text search in definitions - `/fluky-search` - Random word discovery -**Advanced Search Endpoints** +### Advanced Search Endpoints - `/search-advanced` - Advanced search with custom parameters - `/search-entries-advanced` - Advanced search with full entries - `/search-rdf-advanced` - Advanced search in RDF/JSON-LD format -**Entry and Sense Endpoints** +### Entry and Sense Endpoints - `/entries` - Get entry details by ID - `/entries-lite` - Get entry details by ID in lite mode (`UseLiteEndpoints=true`) @@ -383,8 +395,57 @@ The library implements the following Lexicala API endpoints: - `/senses-lite` - Get sense details by ID in lite mode (`UseLiteEndpoints=true`) - `/rdf` - Get entry in RDF/JSON-LD format +### Translation Endpoints + +- `/translate-to` - Translate a lexical unit into a target language +- `/translate-example` - Translate an example sentence into a target language +- `/translate-phrase` - Translate a phrase into a target language + +### Lite Translation Enrichment + +- `GetEntryWithTranslationsAsync` - Retrieves an entry (including lite mode) and enriches missing sense/example translations using translation endpoints +- `GetSenseWithTranslationsAsync` - Retrieves a sense (including lite mode) and enriches missing sense/example translations using translation endpoints + For complete API documentation, visit the [Lexicala API Documentation](https://api.lexicala.com/documentation). +## Rate Limiting + +The Lexicala API enforces a daily request quota. When the quota is exhausted the API returns **HTTP 429 Too Many Requests** and includes an `X-RateLimit-requests-Reset` header indicating how many seconds remain until the quota resets. + +### Retry behaviour + +The client uses a Polly-based retry policy with the following rules: + +| Condition | Behaviour | +|-----------|-----------| +| Transient errors (5xx, network) | Retry up to 3 times with exponential back-off (max 8 s per wait) | +| HTTP 429 — reset ≤ 60 s | Retry up to 3 times, waiting the server-indicated number of seconds between attempts | +| HTTP 429 — reset > 60 s | **Fail immediately** — no retry; a `LexicalaApiException` with `StatusCode = 429` is thrown | + +The 60-second threshold prevents the application from stalling when the quota won't reset for minutes or hours. In that scenario every retry attempt would also receive a 429, so the library surfaces the failure straight away instead of blocking. + +### Logging + +When a request fails due to rate limiting, structured log messages are emitted: + +- **Warning** (before each retry): `Rate limit exceeded (HTTP 429). Waiting {n}s before retry attempt {x}/3.` +- **Warning** (skip-retry path): `Rate limit exceeded (HTTP 429). API quota resets in {n}s which exceeds the retry threshold (60s). Not retrying.` +- **Error** (after final failure): `API rate limit exceeded (HTTP 429). Quota resets in {n}s. Request failed without retrying because the reset time exceeds the retry threshold.` + +### Handling the exception + +```csharp +try +{ + var result = await client.BasicSearchAsync("hello", "en"); +} +catch (LexicalaApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests) +{ + var secondsUntilReset = ex.Metadata?.RateLimits?.Reset; + Console.WriteLine($"Daily quota exceeded. Quota resets in {secondsUntilReset}s."); +} +``` + ## Contributing Contributions are welcome! Please feel free to submit issues and pull requests. diff --git a/changelog.md b/changelog.md index 3b00309..add6da7 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,11 @@ # Change log + ## Lexicala.NET +3.2.0 - Added translation capabilities for lite endpoints, including translation enrichment. Improved response handling consistency across client models. + +3.1.0 - Expanded supported Lexicala API surface with additional search and retrieval endpoints (including advanced search variants). Improved parser robustness for broader response shapes. + 3.0.0 - Major update: Migrated from Newtonsoft.Json to System.Text.Json for better performance. Replaced console tester app with ASP.NET Core minimal Web API host with full Swagger/OpenAPI support. Enhanced error handling and logging. Now targets .NET 10.0 and .NET 8.0 2.0.0 - Now supports .NET 8.0 and .NET standard 2.0. Removed packages Lexicala.NET.Autofac and Lexicala.NET.MicrosoftDependencyInjection. Api calls to old dictapi url were no longer valid, this has been fixed by using the new RapidApi url now. diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs index fbf863c..af37b90 100644 --- a/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/TranslationQuizGameService.cs @@ -6,6 +6,7 @@ using Lexicala.NET.Request; using Lexicala.NET.Response.Entries; using Lexicala.NET.Response.Search; +using Lexicala.NET.Response.Translation; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -232,6 +233,7 @@ public async Task GetTargetLanguagesAsync(CancellationToken cancellati // Find a translation in the target language from any sense var correctTranslation = FindTranslation(entry, targetLanguage); + correctTranslation ??= await FindTranslationViaPhraseEndpointAsync(sourceWord, targetLanguage, cancellationToken); if (string.IsNullOrWhiteSpace(correctTranslation)) { return null; @@ -337,7 +339,7 @@ private static List PickDistractors(IEnumerable pool, string cor private static string GetDistractorPoolCacheKey(string targetLanguage) => $"{DistractorPoolCacheKeyPrefix}{targetLanguage}"; - private static string? FindTranslation(Lexicala.NET.Response.Entries.Entry entry, string targetLanguage) + private static string? FindTranslation(Entry entry, string targetLanguage) { foreach (var sense in entry.Senses) { @@ -356,7 +358,7 @@ private static List PickDistractors(IEnumerable pool, string cor return null; } - private static string? GetFirstHeadword(Lexicala.NET.Response.Search.Result? result) + private static string? GetFirstHeadword(Result? result) { if (result is null) { @@ -369,6 +371,116 @@ private static List PickDistractors(IEnumerable pool, string cor : hw.Headword?.Text; } + private async Task FindTranslationViaPhraseEndpointAsync(string sourceText, string targetLanguage, CancellationToken cancellationToken) + { + try + { + var translated = await _lexicalaClient.TranslateToAsync(sourceText, targetLanguage, "en", cancellationToken: cancellationToken); + var best = TryExtractBestTranslation(translated, sourceText, targetLanguage); + if (!string.IsNullOrWhiteSpace(best)) + { + return best; + } + + translated = await _lexicalaClient.TranslatePhraseAsync(sourceText, targetLanguage, "en", cancellationToken: cancellationToken); + return TryExtractBestTranslation(translated, sourceText, targetLanguage); + } + catch (LexicalaApiException ex) + { + _logger.LogDebug(ex, "Translation fallback failed for source '{SourceText}' to '{TargetLanguage}'", sourceText, targetLanguage); + return null; + } + } + + private static string? TryExtractBestTranslation(TranslationResponse response, string sourceText, string targetLanguage) + { + foreach (var result in response.Results) + { + var candidate = TryExtractText(result, sourceText, targetLanguage, "en"); + if (!string.IsNullOrWhiteSpace(candidate)) + { + return candidate; + } + } + + return null; + } + + private static string? TryExtractText(System.Text.Json.JsonElement element, string sourceText, string targetLanguage, string sourceLanguage) + { + if (element.ValueKind == System.Text.Json.JsonValueKind.Object) + { + if (element.TryGetProperty("translations", out var translationsElement) && + translationsElement.ValueKind == System.Text.Json.JsonValueKind.Object && + translationsElement.TryGetProperty(targetLanguage, out var targetTranslations)) + { + var fromTranslations = TryExtractText(targetTranslations, sourceText, targetLanguage, sourceLanguage); + if (!string.IsNullOrWhiteSpace(fromTranslations)) + { + return fromTranslations; + } + } + + if (element.TryGetProperty("translation", out var translationElement)) + { + var fromTranslation = TryExtractText(translationElement, sourceText, targetLanguage, sourceLanguage); + if (!string.IsNullOrWhiteSpace(fromTranslation)) + { + return fromTranslation; + } + } + + if (element.TryGetProperty("text", out var textElement) && + textElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + var text = textElement.GetString(); + var language = element.TryGetProperty("language", out var languageElement) && languageElement.ValueKind == System.Text.Json.JsonValueKind.String + ? languageElement.GetString() + : null; + + var isSourceLanguageText = !string.IsNullOrWhiteSpace(language) && string.Equals(language, sourceLanguage, StringComparison.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(text) + && !string.Equals(text, sourceText, StringComparison.OrdinalIgnoreCase) + && !isSourceLanguageText) + { + return text; + } + } + + foreach (var property in element.EnumerateObject()) + { + var nested = TryExtractText(property.Value, sourceText, targetLanguage, sourceLanguage); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + var nested = TryExtractText(item, sourceText, targetLanguage, sourceLanguage); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + } + + if (element.ValueKind == System.Text.Json.JsonValueKind.String) + { + var text = element.GetString(); + if (!string.IsNullOrWhiteSpace(text) && !string.Equals(text, sourceText, StringComparison.OrdinalIgnoreCase)) + { + return text; + } + } + + return null; + } + private TranslationQuizRoundState GetRequiredRound(Guid roundId) { if (!_cache.TryGetValue(roundId, out TranslationQuizRoundState? round) || round is null) diff --git a/source/Demo/Lexicala.NET.Demo.Api/Program.cs b/source/Demo/Lexicala.NET.Demo.Api/Program.cs index 93231ad..3e0e44f 100644 --- a/source/Demo/Lexicala.NET.Demo.Api/Program.cs +++ b/source/Demo/Lexicala.NET.Demo.Api/Program.cs @@ -150,7 +150,7 @@ await client.AdvancedSearchEntriesAsync(request, cancellationToken)) .WithSummary("Advanced search in RDF/JSON-LD format") .WithDescription("Search for entries using advanced filter parameters and return results serialised as RDF/JSON-LD."); - app.MapGet("/search-definitions", async (ILexicalaClient client, string text, string? language, string? etag, CancellationToken cancellationToken) => + app.MapGet("/search-by-definitions", async (ILexicalaClient client, string text, string? language, string? etag, CancellationToken cancellationToken) => await client.SearchDefinitionsAsync(text, language, etag, cancellationToken)) .WithName("SearchDefinitions") .WithTags("Definitions") diff --git a/source/Demo/Lexicala.NET.Demo.Api/Properties/launchSettings.json b/source/Demo/Lexicala.NET.Demo.Api/Properties/launchSettings.json new file mode 100644 index 0000000..545d84e --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Lexicala.NET.Demo.Api": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:5001;http://localhost:5000" + } + } +} \ No newline at end of file diff --git a/source/Demo/Lexicala.NET.Demo.Api/appsettings.json b/source/Demo/Lexicala.NET.Demo.Api/appsettings.json index 544b7b4..3570976 100644 --- a/source/Demo/Lexicala.NET.Demo.Api/appsettings.json +++ b/source/Demo/Lexicala.NET.Demo.Api/appsettings.json @@ -1,3 +1,5 @@ { - + "Lexicala": { + "UseLiteEndpoints": true + } } \ No newline at end of file diff --git a/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj b/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj index ea23b1a..1de3828 100644 --- a/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj +++ b/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj @@ -26,6 +26,9 @@ + + + @@ -49,7 +52,10 @@ + + + diff --git a/source/Lexicala.NET.Tests/LexicalaClientEntryTests.cs b/source/Lexicala.NET.Tests/LexicalaClientEntryTests.cs index c746c4d..abab8b0 100644 --- a/source/Lexicala.NET.Tests/LexicalaClientEntryTests.cs +++ b/source/Lexicala.NET.Tests/LexicalaClientEntryTests.cs @@ -118,6 +118,52 @@ public async Task LexicalaClient_CanDeserializeEntry_ES_DE00019850() await AssertEntryDeserializes("ES_DE00019850.json", "ES_DE00019850"); } + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntryLite_ES_DE55546d93a4a9() + { + InitializeClient(useLiteEndpoints: true); + await AssertEntryDeserializes("Entry-lite_ES_DE55546d93a4a9.json", "ES_DE55546d93a4a9"); + } + + [TestMethod] + public async Task LexicalaClient_EntryLite_ParsesHeadword() + { + InitializeClient(useLiteEndpoints: true); + string response = await LoadResponseFromFile("Entry-lite_ES_DE55546d93a4a9.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.GetEntryAsync("ES_DE55546d93a4a9"); + + result.Language.ShouldBe("es"); + result.Source.ShouldBe("global"); + result.Headwords.Length.ShouldBe(1); + result.Headwords[0].Text.ShouldBe("trepar"); + } + + [TestMethod] + public async Task LexicalaClient_EntryLite_ParsesSensesWithAvailableTranslations() + { + InitializeClient(useLiteEndpoints: true); + string response = await LoadResponseFromFile("Entry-lite_ES_DE55546d93a4a9.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.GetEntryAsync("ES_DE55546d93a4a9"); + + result.Senses.Length.ShouldBe(3); + result.Senses[0].Id.ShouldBe("ES_SEc436bcfd17e2"); + result.Senses[0].Definition.ShouldBe("subir una altura usando los pies y las manos"); + result.Senses[0].Synonyms.ShouldContain("escalar"); + result.Senses[0].Translations.ShouldBeEmpty(); + result.Senses[0].AvailableTranslations.ShouldContain("en"); + result.Senses[0].AvailableTranslations.Length.ShouldBe(7); + } + [TestMethod] public async Task LexicalaClient_SearchEntries_Basic_IncludesSearchEntriesEndpoint() { diff --git a/source/Lexicala.NET.Tests/LexicalaClientFlukySearchTests.cs b/source/Lexicala.NET.Tests/LexicalaClientFlukySearchTests.cs index d70466e..2fa918c 100644 --- a/source/Lexicala.NET.Tests/LexicalaClientFlukySearchTests.cs +++ b/source/Lexicala.NET.Tests/LexicalaClientFlukySearchTests.cs @@ -72,5 +72,54 @@ public async Task LexicalaClient_FlukySearch_SingleResultPayload_MapsToResultsAr response.Results.Length.ShouldBe(1); response.Results[0].Id.ShouldBe("EN_TEST"); } + + [TestMethod] + public async Task LexicalaClient_FlukySearch_RealPayload_ParsesId() + { + string json = await LoadResponseFromFile("fluky-search.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(json)); + + var response = await Client.FlukySearchAsync(source: Sources.Global, language: "en"); + + response.NResults.ShouldBe(1); + response.Results.Length.ShouldBe(1); + response.Results[0].Id.ShouldBe("EN_DEd1d432e9f98e"); + } + + [TestMethod] + public async Task LexicalaClient_FlukySearch_RealPayload_ParsesHeadword() + { + string json = await LoadResponseFromFile("fluky-search.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(json)); + + var response = await Client.FlukySearchAsync(source: Sources.Global, language: "en"); + + var entry = response.Results[0]; + entry.Language.ShouldBe("en"); + entry.Headword.Headword.Text.ShouldBe("streak"); + } + + [TestMethod] + public async Task LexicalaClient_FlukySearch_RealPayload_ParsesSenses() + { + string json = await LoadResponseFromFile("fluky-search.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(json)); + + var response = await Client.FlukySearchAsync(source: Sources.Global, language: "en"); + + var entry = response.Results[0]; + entry.Senses.Length.ShouldBe(2); + entry.Senses[0].Id.ShouldBe("EN_SE7dceb9fb5542"); + entry.Senses[0].Definition.ShouldBe("to move somewhere very quickly"); + } } } diff --git a/source/Lexicala.NET.Tests/LexicalaClientSearchDefinitionsTests.cs b/source/Lexicala.NET.Tests/LexicalaClientSearchDefinitionsTests.cs new file mode 100644 index 0000000..ef87e20 --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientSearchDefinitionsTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; +using Shouldly; + +namespace Lexicala.NET.Client.Tests +{ + [TestClass] + public class LexicalaClientSearchDefinitionsTests : LexicalaClientTestBase + { + [TestMethod] + public async Task LexicalaClient_SearchDefinitions_EmptySearchText_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.SearchDefinitionsAsync("")); + } + + [TestMethod] + public async Task LexicalaClient_SearchDefinitions_En_Summer() + { + string response = await LoadResponseFromFile("Search_en_summer_definitions.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.SearchDefinitionsAsync("summer", "en"); + + result.ResultsPerPage.ShouldBe(10); + result.Results.Length.ShouldBe(10); + } + + [TestMethod] + public async Task LexicalaClient_SearchDefinitions_En_Summer_ResultsHaveEntryId() + { + string response = await LoadResponseFromFile("Search_en_summer_definitions.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.SearchDefinitionsAsync("summer", "en"); + + result.Results[0].EntryId.ShouldBe("EN00009942"); + result.Results[0].Definition.ShouldBe("a vacation from school or college in the spring"); + result.Results[0].Pos.ShouldBe("noun"); + result.Results[0].SenseId.ShouldBe("EN_SE4458d852734b"); + } + + [TestMethod] + public async Task LexicalaClient_SearchDefinitions_En_Summer_HeadwordStringDeserializes() + { + string response = await LoadResponseFromFile("Search_en_summer_definitions.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.SearchDefinitionsAsync("summer", "en"); + + result.Results[0].Headword.Headword.Text.ShouldBe("spring break"); + } + + [TestMethod] + public async Task LexicalaClient_SearchDefinitions_En_Summer_ResultWithoutHeadword() + { + string response = await LoadResponseFromFile("Search_en_summer_definitions.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.SearchDefinitionsAsync("summer", "en"); + + // Index 7 has no headword in the response + result.Results[7].EntryId.ShouldBe("EN00010306"); + result.Results[7].Headword.Headword.ShouldBeNull(); + result.Results[7].Definition.ShouldBe("the period during the summer when schools, universities, etc. are closed"); + } + + [TestMethod] + public async Task LexicalaClient_SearchDefinitions_BuildsCorrectQuery() + { + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage("{\"results_per_page\":10,\"results\":[]}")); + + await Client.SearchDefinitionsAsync("summer", "en"); + + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => + req.RequestUri.ToString().Contains("search-by-definitions") && + req.RequestUri.ToString().Contains("text=summer") && + req.RequestUri.ToString().Contains("language=en")), + ItExpr.IsAny()); + } + } +} diff --git a/source/Lexicala.NET.Tests/LexicalaClientTestBase.cs b/source/Lexicala.NET.Tests/LexicalaClientTestBase.cs index 91f41bd..6997e47 100644 --- a/source/Lexicala.NET.Tests/LexicalaClientTestBase.cs +++ b/source/Lexicala.NET.Tests/LexicalaClientTestBase.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Extensions.Options; using Moq; using Moq.AutoMock; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -37,12 +38,12 @@ protected void InitializeClient(bool useLiteEndpoints) Client = CreateClient(httpClient, useLiteEndpoints); } - private static LexicalaClient CreateClient(HttpClient httpClient, bool useLiteEndpoints) + protected LexicalaClient CreateClient(HttpClient httpClient, bool useLiteEndpoints) { var mocker = new AutoMocker(MockBehavior.Loose); mocker.Use(httpClient); - mocker.Use(new LexicalaConfig("test-key", useLiteEndpoints)); - + var config = new LexicalaConfig("test-key", useLiteEndpoints); + mocker.Use>(Options.Create(config)); return mocker.CreateInstance(); } diff --git a/source/Lexicala.NET.Tests/LexicalaClientTranslationTests.cs b/source/Lexicala.NET.Tests/LexicalaClientTranslationTests.cs new file mode 100644 index 0000000..2d12464 --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientTranslationTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; +using Shouldly; +using Lexicala.NET.Client.Tests; + +namespace Lexicala.NET.Tests +{ + [TestClass] + public class LexicalaClientTranslationTests : LexicalaClientTestBase + { + [TestMethod] + public async Task LexicalaClient_TranslatePhrase_EmptyText_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.TranslatePhraseAsync("", "de")); + } + + [TestMethod] + public async Task LexicalaClient_TranslatePhrase_InvalidTargetLanguage_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.TranslatePhraseAsync("house", "deu")); + } + + [TestMethod] + public async Task LexicalaClient_TranslateTo_UsesExpectedEndpoint() + { + const string response = "{\"n_results\":0,\"page_number\":1,\"results_per_page\":10,\"n_pages\":0,\"results\":[]}"; + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.TranslateToAsync("house", "de", "en"); + + result.NResults.ShouldBe(0); + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == "http://www.tempuri.org/translate-to?target_language=de&text=house&language=en"), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_TranslateExample_UsesExpectedEndpoint() + { + const string response = "{\"n_results\":0,\"page_number\":1,\"results_per_page\":10,\"n_pages\":0,\"results\":[]}"; + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.TranslateExampleAsync("A house is big.", "de", "en"); + + result.PageNumber.ShouldBe(1); + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == "http://www.tempuri.org/translate-example?target_language=de&text=A house is big.&language=en"), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_TranslatePhrase_ParsesResults() + { + const string response = "{\"n_results\":1,\"page_number\":1,\"results_per_page\":10,\"n_pages\":1,\"results\":[{\"text\":\"Haus\"}]}"; + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.TranslatePhraseAsync("house", "de"); + + result.NResults.ShouldBe(1); + result.Results.Length.ShouldBe(1); + result.Results[0].GetProperty("text").GetString().ShouldBe("Haus"); + } + + [TestMethod] + public async Task LexicalaClient_GetEntryWithTranslations_EnrichesSenseAndExample() + { + InitializeClient(useLiteEndpoints: true); + + const string entryResponse = "{\"id\":\"ES_TEST\",\"source\":\"global\",\"language\":\"es\",\"version\":1,\"headword\":{\"text\":\"casa\"},\"senses\":[{\"id\":\"ES_SE_TEST\",\"definition\":\"vivienda\",\"translations\":{},\"examples\":[{\"text\":\"La casa es grande.\"}]}]}"; + const string translateToResponse = "{\"n_results\":1,\"page_number\":1,\"results_per_page\":10,\"n_pages\":1,\"results\":[{\"text\":\"house\"}]}"; + const string translateExampleResponse = "{\"n_results\":1,\"page_number\":1,\"results_per_page\":10,\"n_pages\":1,\"results\":[{\"text\":\"The house is big.\"}]}"; + + HandlerMock.Protected() + .SetupSequence>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(entryResponse)) + .ReturnsAsync(SetupOkResponseMessage(translateToResponse)) + .ReturnsAsync(SetupOkResponseMessage(translateExampleResponse)); + + var result = await Client.GetEntryWithTranslationsAsync("ES_TEST", "en"); + + result.Senses[0].Translations.ContainsKey("en").ShouldBeTrue(); + result.Senses[0].Translations["en"].Translation.Text.ShouldBe("house"); + result.Senses[0].Examples[0].Translations.ContainsKey("en").ShouldBeTrue(); + result.Senses[0].Examples[0].Translations["en"].Translation.Text.ShouldBe("The house is big."); + } + + [TestMethod] + public async Task LexicalaClient_GetSenseWithTranslations_EnrichesSenseAndExample() + { + const string senseResponse = "{\"id\":\"ES_SE_TEST\",\"definition\":\"casa\",\"translations\":{},\"examples\":[{\"text\":\"La casa es grande.\"}]}"; + const string translateToResponse = "{\"n_results\":1,\"page_number\":1,\"results_per_page\":10,\"n_pages\":1,\"results\":[{\"text\":\"house\"}]}"; + const string translateExampleResponse = "{\"n_results\":1,\"page_number\":1,\"results_per_page\":10,\"n_pages\":1,\"results\":[{\"text\":\"The house is big.\"}]}"; + + HandlerMock.Protected() + .SetupSequence>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(senseResponse)) + .ReturnsAsync(SetupOkResponseMessage(translateToResponse)) + .ReturnsAsync(SetupOkResponseMessage(translateExampleResponse)); + + var result = await Client.GetSenseWithTranslationsAsync("ES_SE_TEST", "en"); + + result.Translations.ContainsKey("en").ShouldBeTrue(); + result.Translations["en"].Translation.Text.ShouldBe("house"); + result.Examples[0].Translations.ContainsKey("en").ShouldBeTrue(); + result.Examples[0].Translations["en"].Translation.Text.ShouldBe("The house is big."); + } + } +} diff --git a/source/Lexicala.NET.Tests/Resources/Entry-lite_ES_DE55546d93a4a9.json b/source/Lexicala.NET.Tests/Resources/Entry-lite_ES_DE55546d93a4a9.json new file mode 100644 index 0000000..b40608a --- /dev/null +++ b/source/Lexicala.NET.Tests/Resources/Entry-lite_ES_DE55546d93a4a9.json @@ -0,0 +1,83 @@ +{ + "id": "ES_DE55546d93a4a9", + "source": "global", + "language": "es", + "version": 1, + "frequency": "75439", + "headword": { + "text": "trepar", + "pronunciation": { + "value": "tɾe'paɾ" + }, + "pos": "verb", + "valency": "intransitive" + }, + "senses": [ + { + "id": "ES_SEc436bcfd17e2", + "definition": "subir una altura usando los pies y las manos", + "synonyms": [ + "escalar" + ], + "examples": [ + { + "text": "Trepó al tejado para buscar una pelota." + }, + { + "text": "Trepan con gran habilidad para recoger los dátiles." + } + ], + "available_translations": [ + "br", + "da", + "en", + "ja", + "nl", + "no", + "sv" + ] + }, + { + "id": "ES_SEdbcb0f7d59f8", + "definition": "crecer enredándose en un soporte", + "semantic_subcategory": "referido a ciertas plantas", + "examples": [ + { + "text": "La enredadera trepó por la pared y la cubrió totalmente." + }, + { + "text": "La hiedra trepa por el muro." + } + ], + "available_translations": [ + "br", + "da", + "en", + "ja", + "nl", + "sv" + ] + }, + { + "id": "ES_SE632a351f4872", + "definition": "subir de jerarquía en un trabajo valiéndose de cualquier medio", + "semantic_subcategory": "ascender", + "examples": [ + { + "text": "Logró trepar hasta la gerencia a costa de sus compañeros." + }, + { + "text": "Hará cualquier cosa con tal de trepar en la empresa." + } + ], + "available_translations": [ + "br", + "da", + "en", + "ja", + "nl", + "sv" + ] + } + ] +} \ No newline at end of file diff --git a/source/Lexicala.NET.Tests/Resources/Search_en_summer_definitions.json b/source/Lexicala.NET.Tests/Resources/Search_en_summer_definitions.json new file mode 100644 index 0000000..51ab331 --- /dev/null +++ b/source/Lexicala.NET.Tests/Resources/Search_en_summer_definitions.json @@ -0,0 +1 @@ +{"results_per_page":10,"results":[{"language":"en","entry_id":"EN00009942","headword":"spring break","pos":"noun","sense_id":"EN_SE4458d852734b","definition":"a vacation from school or college in the spring"},{"language":"en","entry_id":"EN00003609","headword":"fall","pos":"noun","sense_id":"EN_SEf4b22cd2b0f8","definition":"the season between summer and winter"},{"language":"en","entry_id":"EN00000591","headword":"autumn","pos":"noun","sense_id":"EN_SE61a94489a06f","definition":"a season between summer and winter"},{"language":"en","entry_id":"EN00004869","headword":"holiday","pos":"noun","sense_id":"EN_SE46059ddee9aa","definition":"vacation"},{"language":"en","entry_id":"EN00008225","headword":"pursuit","pos":"noun","sense_id":"EN_SE3ab643e3b1e7","definition":"a leisure activity"},{"language":"en","entry_id":"EN00011992","headword":"yr.","pos":"abbreviation","sense_id":"EN_SE42d28d1927ac","definition":"year"},{"language":"en","entry_id":"EN00003801","headword":"fine","pos":"adjective","sense_id":"EN_SEc9f666cc62d7","definition":"(of weather) sunny"},{"language":"en","entry_id":"EN00010306","sense_id":"EN_SE040d379f3437","definition":"the period during the summer when schools, universities, etc. are closed"},{"language":"en","entry_id":"EN00010303","headword":"summer","pos":"noun","sense_id":"EN_SE4ff10a993fa2","definition":"the season of the year when the weather is usually at its hottest"},{"language":"en","entry_id":"EN00009488","headword":"simple","pos":"adjective","sense_id":"EN_SE9089d2c92ff1","definition":"easy"}]} diff --git a/source/Lexicala.NET.Tests/Resources/fluky-search.json b/source/Lexicala.NET.Tests/Resources/fluky-search.json new file mode 100644 index 0000000..d71c4c2 --- /dev/null +++ b/source/Lexicala.NET.Tests/Resources/fluky-search.json @@ -0,0 +1,213 @@ +{ + "id": "EN_DEd1d432e9f98e", + "source": "global", + "language": "en", + "filename": "mlds-EN-1086.xml", + "entry_id": "EN00010162", + "version": 1, + "frequency": "48008", + "headword": { + "text": "streak", + "pronunciation": { + "value": "strik" + }, + "pos": "verb", + "homograph_number": 2, + "valency": "intransitive", + "additional_inflections": [ + "streaked", + "streaked", + "streaking", + "streaks" + ] + }, + "senses": [ + { + "id": "EN_SE7dceb9fb5542", + "definition": "to move somewhere very quickly", + "translations": { + "id": "TC00046665", + "br": { + "text": "mover-se rapidamente" + }, + "ca": { + "text": "anar molt ràpid", + "gender": "masculine", + "inflections": [ + { + "text": "anar molt ràpida", + "gender": "feminine" + } + ] + }, + "da": { + "text": "stryge" + }, + "es": [ + { + "text": "ir rápidamente" + }, + { + "text": "entrar/salir/pasar como un rayo" + } + ], + "fr": { + "text": "passer comme une flèche" + }, + "ja": { + "text": "疾走(しっそう)する", + "alternative_scripts": [ + { + "type": "romaji", + "text": "shissoo suru" + } + ] + }, + "no": { + "text": "suse" + }, + "pl": { + "text": "poszybować" + }, + "sv": { + "text": "stryka (över)" + }, + "vl": { + "text": "anar molt ràpid", + "gender": "masculine", + "inflections": [ + { + "text": "anar molt ràpida", + "gender": "feminine" + } + ] + } + }, + "examples": [ + { + "text": "A military jet streaked across the sky.", + "translations": { + "id": "TC00046666", + "br": { + "text": "Um jato militar cruzou o céu rapidamente." + }, + "da": { + "text": "Et militærfly strøg hen over himlen." + }, + "es": { + "text": "Un avión militar cruzó el cielo como un rayo." + }, + "fr": { + "text": "Un avion à réaction militaire traversa le ciel comme une flèche." + }, + "ja": { + "text": "軍用機が空を横切った。", + "alternative_scripts": [ + { + "type": "romaji", + "text": "Gun=yooki ga sora o yokogitta." + } + ] + }, + "no": { + "text": "Et militært jagerfly suste over himmelen." + }, + "pl": { + "text": "Wojskowy odrzutowiec poszybował po niebie." + }, + "sv": { + "text": "Ett jetplan från militären strök över himlen." + } + } + } + ] + }, + { + "id": "EN_SE2410a3fa1b0c", + "definition": "to make streaks on something", + "translations": { + "id": "TC00046667", + "br": { + "text": "riscar" + }, + "ca": { + "text": "solcar" + }, + "da": [ + { + "text": "plette" + }, + { + "text": "lave striber" + } + ], + "es": { + "text": "golpear" + }, + "fr": { + "text": "strier" + }, + "ja": { + "text": "縞(しま)にする", + "alternative_scripts": [ + { + "type": "romaji", + "text": "shima ni suru" + } + ] + }, + "no": { + "text": "lage striper" + }, + "pl": { + "text": "spływać" + }, + "sv": { + "text": "göra rännilar" + }, + "vl": { + "text": "solcar" + } + }, + "examples": [ + { + "text": "Rain streaked the windows.", + "translations": { + "id": "TC00046668", + "br": { + "text": "A chuva riscava as janelas." + }, + "da": { + "text": "Regnen lavede striber på ruderne." + }, + "es": { + "text": "La lluvia golpeaba las ventanas." + }, + "fr": { + "text": "La pluie stria les fenêtres." + }, + "ja": { + "text": "雨が窓に当たり流れた。", + "alternative_scripts": [ + { + "type": "romaji", + "text": "Ame ga mado ni atari nagareta." + } + ] + }, + "no": { + "text": "Regnet lagde striper på vinduene." + }, + "pl": { + "text": "Deszcz spływał po oknach." + }, + "sv": { + "text": "Regnet gjorde rännilar på fönstren." + } + } + } + ] + } + ], + "senses_len": 2 +} \ No newline at end of file diff --git a/source/Lexicala.NET.Tests/TranslationQuizGameServiceTests.cs b/source/Lexicala.NET.Tests/TranslationQuizGameServiceTests.cs index dfe3c4d..38ddedc 100644 --- a/source/Lexicala.NET.Tests/TranslationQuizGameServiceTests.cs +++ b/source/Lexicala.NET.Tests/TranslationQuizGameServiceTests.cs @@ -6,6 +6,7 @@ using Lexicala.NET.Response.Entries; using Lexicala.NET.Response.Languages; using Lexicala.NET.Response.Search; +using Lexicala.NET.Response.Translation; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -224,6 +225,71 @@ await Should.ThrowAsync(() => _service.SubmitAnswerAsync(Guid.NewGuid(), null!)); } + [TestMethod] + public async Task CreateRoundAsync_TranslationFallback_DoesNotUseSourceLanguageText() + { + _clientMock + .Setup(c => c.FlukySearchAsync(It.IsAny(), "en", It.IsAny(), It.IsAny())) + .ReturnsAsync(new SearchResponse + { + Results = [new Result { Id = "EN0001" }] + }); + + _clientMock + .Setup(c => c.GetEntryAsync("EN0001", null, It.IsAny())) + .ReturnsAsync(new Entry + { + Id = "EN0001", + Language = "en", + HeadwordObject = new Lexicala.NET.Response.Entries.Headword { Text = "Given" }, + Senses = + [ + new Lexicala.NET.Response.Entries.Sense + { + Definition = "already stated", + Translations = new Dictionary() + } + ] + }); + + // translate-to cannot provide a usable value, forcing phrase fallback + _clientMock + .Setup(c => c.TranslateToAsync("Given", "es", "en", null, It.IsAny())) + .ReturnsAsync(new TranslationResponse + { + Results = [] + }); + + // phrase response includes source-language text and nested target translation + _clientMock + .Setup(c => c.TranslatePhraseAsync("Given", "es", "en", null, It.IsAny())) + .ReturnsAsync(new TranslationResponse + { + Results = + [ + System.Text.Json.JsonSerializer.Deserialize( + "{\"lemma\":\"given\",\"language\":\"en\",\"text\":\"given that\",\"target_language\":\"es\",\"translation\":[{\"text\":\"dado que\"}]}" + ) + ] + }); + + var distractorResponses = new Queue( + [ + BuildDistractorResponse("casa"), + BuildDistractorResponse("perro"), + BuildDistractorResponse("árbol") + ]); + + _clientMock + .Setup(c => c.FlukySearchAsync(It.IsAny(), "es", It.IsAny(), It.IsAny())) + .ReturnsAsync(() => distractorResponses.Dequeue()); + + var round = await _service.CreateRoundAsync("es", CancellationToken.None); + + round.Choices.ShouldContain("dado que"); + round.Choices.ShouldNotContain("given that"); + } + [TestMethod] public async Task SubmitAnswerAsync_EmptyChoice_ThrowsArgumentException() { diff --git a/source/Lexicala.NET/Constants.cs b/source/Lexicala.NET/Constants.cs index e24ffff..4f5b44e 100644 --- a/source/Lexicala.NET/Constants.cs +++ b/source/Lexicala.NET/Constants.cs @@ -63,13 +63,28 @@ internal static class Constants /// /// Search definitions endpoint path. /// - internal const string SearchDefinitions = "/search-definitions"; + internal const string SearchDefinitions = "/search-by-definitions"; /// /// Fluky search endpoint path (random word discovery). /// internal const string FlukySearch = "/fluky-search"; + /// + /// Translate to endpoint path. + /// + internal const string TranslateTo = "/translate-to"; + + /// + /// Translate example endpoint path. + /// + internal const string TranslateExample = "/translate-example"; + + /// + /// Translate phrase endpoint path. + /// + internal const string TranslatePhrase = "/translate-phrase"; + /// /// Maximum threshold for pagination and sampling parameters to prevent excessive API requests. /// Used to validate page numbers and sample sizes submitted by clients. diff --git a/source/Lexicala.NET/DependencyRegistration.cs b/source/Lexicala.NET/DependencyRegistration.cs index 07bbc75..1d16b9d 100644 --- a/source/Lexicala.NET/DependencyRegistration.cs +++ b/source/Lexicala.NET/DependencyRegistration.cs @@ -7,6 +7,7 @@ using Lexicala.NET.Response; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Polly; using Polly.Extensions.Http; @@ -17,61 +18,106 @@ namespace Lexicala.NET /// public static class DependencyRegistration { - /// - /// Registers Lexicala services using configuration from the "Lexicala" section. - /// - /// The service collection. - /// The application configuration. - /// The updated service collection. - public static IServiceCollection RegisterLexicala(this IServiceCollection services, IConfiguration configuration) - { - var config = configuration.GetSection("Lexicala").Get(); - return RegisterLexicala(services, config); - } - - /// - /// Registers Lexicala services using an explicit configuration object. - /// /// The service collection. - /// The Lexicala configuration. - /// The updated service collection. - /// Thrown when is . - /// Thrown when is missing. - public static IServiceCollection RegisterLexicala(this IServiceCollection services, LexicalaConfig config) + extension(IServiceCollection services) { - if (config == null) + /// + /// Registers Lexicala services using configuration from the "Lexicala" section. + /// + /// The application configuration. + /// The updated service collection. + public IServiceCollection RegisterLexicala(IConfiguration configuration) { - throw new ArgumentNullException(nameof(config)); + services.Configure(configuration.GetSection("Lexicala")); + return RegisterLexicala(services); } - if (string.IsNullOrWhiteSpace(config.ApiKey)) + /// + /// Registers Lexicala services using an explicit configuration object. + /// + /// The Lexicala configuration. + /// The updated service collection. + public IServiceCollection RegisterLexicala(LexicalaConfig config) { - throw new ArgumentException("ApiKey must be provided and cannot be empty", nameof(config.ApiKey)); + services.Configure(o => + { + o.ApiKey = config.ApiKey; + o.UseLiteEndpoints = config.UseLiteEndpoints; + }); + return RegisterLexicala(services); } - services.AddHttpClient(client => - { - client.BaseAddress = LexicalaConfig.BaseAddress; - client.DefaultRequestHeaders.Add(LexicalaConfig.RapidApiKeyHeader, config.ApiKey); - client.DefaultRequestHeaders.Add(LexicalaConfig.RapidApiHostHeader, LexicalaConfig.RapidApiHostValue); - }) - .AddPolicyHandler(CreateRetryPolicy()); + private IServiceCollection RegisterLexicala() + { + services.AddHttpClient((provider, client) => + { + var config = provider.GetRequiredService>().Value; + client.BaseAddress = LexicalaConfig.BaseAddress; + client.DefaultRequestHeaders.Add(LexicalaConfig.RapidApiKeyHeader, config.ApiKey); + client.DefaultRequestHeaders.Add(LexicalaConfig.RapidApiHostHeader, LexicalaConfig.RapidApiHostValue); + }) + .AddPolicyHandler((serviceProvider, _) => + CreateRetryPolicy(serviceProvider.GetRequiredService().CreateLogger())); - services.AddSingleton(config); - services.AddMemoryCache(); - services.AddSingleton(); + services.AddMemoryCache(); + services.AddSingleton(); - return services; + return services; + } } - private static IAsyncPolicy CreateRetryPolicy() + /// + /// The maximum delay the retry policy will wait between attempts. + /// If the API signals a reset time greater than this threshold, the request fails + /// immediately instead of retrying — there is no point waiting longer than this + /// because any subsequent attempt would also exceed the remaining quota window. + /// + private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromSeconds(60); + + private static IAsyncPolicy CreateRetryPolicy(ILogger logger) { return HttpPolicyExtensions .HandleTransientHttpError() - .OrResult(response => response.StatusCode == HttpStatusCode.TooManyRequests) + .OrResult(response => + { + if (response.StatusCode != HttpStatusCode.TooManyRequests) + { + return false; + } + + // Only retry if the rate-limit reset window fits within our max delay. + // If the server says the quota won't reset for longer than MaxRetryDelay, + // retrying would never succeed within that window — fail immediately. + if (response.Headers.TryGetValues(ResponseHeaders.HeaderRateLimitReset, out var resetValues) && + int.TryParse(resetValues.FirstOrDefault(), out var resetSeconds) && + resetSeconds > (int)MaxRetryDelay.TotalSeconds) + { + logger.LogWarning( + "Rate limit exceeded (HTTP 429). API quota resets in {ResetSeconds}s which exceeds the retry threshold ({ThresholdSec}s). Not retrying.", + resetSeconds, (int)MaxRetryDelay.TotalSeconds); + return false; + } + + return true; + }) .RetryAsync(3, async (outcome, retryAttempt, _) => { var retryDelay = GetRetryDelay(outcome.Result, retryAttempt); + + if (outcome.Result?.StatusCode == HttpStatusCode.TooManyRequests) + { + logger.LogWarning( + "Rate limit exceeded (HTTP 429). Waiting {RetryDelaySec}s before retry attempt {RetryAttempt}/3.", + (int)retryDelay.TotalSeconds, retryAttempt); + } + else + { + logger.LogWarning( + outcome.Exception, + "Request failed with status {StatusCode}. Waiting {RetryDelaySec}s before retry attempt {RetryAttempt}/3.", + outcome.Result?.StatusCode, (int)retryDelay.TotalSeconds, retryAttempt); + } + if (retryDelay > TimeSpan.Zero) { await Task.Delay(retryDelay); @@ -83,12 +129,12 @@ private static TimeSpan GetRetryDelay(HttpResponseMessage response, int retryAtt { if (response != null) { - if (response.Headers.RetryAfter?.Delta is TimeSpan delta && delta > TimeSpan.Zero) + if (response.Headers.RetryAfter?.Delta is { } delta && delta > TimeSpan.Zero) { return delta; } - if (response.Headers.RetryAfter?.Date is DateTimeOffset date) + if (response.Headers.RetryAfter?.Date is { } date) { var retryAfterDateDelay = date - DateTimeOffset.UtcNow; if (retryAfterDateDelay > TimeSpan.Zero) diff --git a/source/Lexicala.NET/ILexicalaClient.cs b/source/Lexicala.NET/ILexicalaClient.cs index 304b3c4..c3d14db 100644 --- a/source/Lexicala.NET/ILexicalaClient.cs +++ b/source/Lexicala.NET/ILexicalaClient.cs @@ -7,6 +7,7 @@ using Lexicala.NET.Response.Languages; using Lexicala.NET.Response.Search; using Lexicala.NET.Response.Test; +using Lexicala.NET.Response.Translation; namespace Lexicala.NET { @@ -148,5 +149,63 @@ public interface ILexicalaClient /// Thrown when source is invalid, or when language is provided but is not a valid 2-character language code. /// Thrown when the API returns an error. Task FlukySearchAsync(string source = "global", string language = null, string etag = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves an entry by ID and enriches missing lite translation fields using translation endpoints. + /// + /// The entry ID. + /// The 2-character target language code for enrichment. + /// Optional. + /// Token used to cancel the request. + /// Thrown when entryId is null/empty or targetLanguage is invalid. + /// Thrown when the API returns an error. + Task GetEntryWithTranslationsAsync(string entryId, string targetLanguage, string etag = null, CancellationToken cancellationToken = default); + + /// + /// Retrieves a sense by ID and enriches missing lite translation fields using translation endpoints. + /// + /// The sense ID. + /// The 2-character target language code for enrichment. + /// Optional. + /// Token used to cancel the request. + /// Thrown when senseId is null/empty or targetLanguage is invalid. + /// Thrown when the API returns an error. + Task GetSenseWithTranslationsAsync(string senseId, string targetLanguage, string etag = null, CancellationToken cancellationToken = default); + + /// + /// Translates a lexical unit into a target language. + /// + /// The source text to translate. + /// The 2-character target language code. + /// Optional 2-character source language code. + /// Optional. + /// Token used to cancel the request. + /// Thrown when text is null/empty, targetLanguage is invalid, or language is provided but invalid. + /// Thrown when the API returns an error. + Task TranslateToAsync(string text, string targetLanguage, string language = null, string etag = null, CancellationToken cancellationToken = default); + + /// + /// Translates an example sentence into a target language. + /// + /// The source sentence to translate. + /// The 2-character target language code. + /// Optional 2-character source language code. + /// Optional. + /// Token used to cancel the request. + /// Thrown when text is null/empty, targetLanguage is invalid, or language is provided but invalid. + /// Thrown when the API returns an error. + Task TranslateExampleAsync(string text, string targetLanguage, string language = null, string etag = null, CancellationToken cancellationToken = default); + + /// + /// Translates a phrase into a target language. + /// + /// The source phrase to translate. + /// The 2-character target language code. + /// Optional 2-character source language code. + /// Optional. + /// Token used to cancel the request. + /// Thrown when text is null/empty, targetLanguage is invalid, or language is provided but invalid. + /// Thrown when the API returns an error. + Task TranslatePhraseAsync(string text, string targetLanguage, string language = null, string etag = null, CancellationToken cancellationToken = default); } } \ No newline at end of file diff --git a/source/Lexicala.NET/LexicalaClient.cs b/source/Lexicala.NET/LexicalaClient.cs index 01cc4a5..31b0b93 100644 --- a/source/Lexicala.NET/LexicalaClient.cs +++ b/source/Lexicala.NET/LexicalaClient.cs @@ -12,7 +12,10 @@ using Lexicala.NET.Response.Languages; using Lexicala.NET.Response.Search; using Lexicala.NET.Response.Test; +using Lexicala.NET.Response.Translation; +using Lexicala.NET.Internal; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Sense = Lexicala.NET.Response.Entries.Sense; namespace Lexicala.NET @@ -23,30 +26,27 @@ public class LexicalaClient : ILexicalaClient private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly bool _useLiteEndpoints; + private readonly TranslationEnricher _translationEnricher = new(); private string SearchEntriesEndpoint => _useLiteEndpoints ? Constants.SearchEntriesLite : Constants.SearchEntries; private string EntriesEndpoint => _useLiteEndpoints ? Constants.EntriesLite : Constants.Entries; private string SensesEndpoint => _useLiteEndpoints ? Constants.SensesLite : Constants.Senses; /// - /// Creates a new instance of the class. + /// Creates a new instance of the class using DI configuration. /// + /// The injected HttpClient instance. + /// The injected logger instance. + /// The injected configuration options for Lexicala. /// - /// This class should not be instantiated directly, but registered as implementation of the interface in the dependency injection framework. + /// This constructor is intended for use with Microsoft.Extensions.DependencyInjection and IOptions pattern. /// - public LexicalaClient(HttpClient httpClient, ILogger logger) - : this(httpClient, logger, new LexicalaConfig()) - { - } - - /// - /// Creates a new instance of the class with endpoint mode configuration. - /// - public LexicalaClient(HttpClient httpClient, ILogger logger, LexicalaConfig config) + public LexicalaClient(HttpClient httpClient, ILogger logger, IOptions configOptions) { _httpClient = httpClient; _logger = logger; - _useLiteEndpoints = config?.UseLiteEndpoints ?? false; + var config = configOptions?.Value ?? new LexicalaConfig(); + _useLiteEndpoints = config.UseLiteEndpoints; } /// @@ -145,6 +145,20 @@ public async Task GetEntryAsync(string entryId, string etag = null, Cance return responseObject; } + /// + public async Task GetEntryWithTranslationsAsync(string entryId, string targetLanguage, string etag = null, CancellationToken cancellationToken = default) + { + ValidateLanguageCode(targetLanguage, nameof(targetLanguage)); + var entry = await GetEntryAsync(entryId, etag, cancellationToken); + await _translationEnricher.EnrichEntryTranslationsAsync( + entry, + targetLanguage, + TranslateToForEnrichmentAsync, + TranslateExampleForEnrichmentAsync, + cancellationToken); + return entry; + } + /// public async Task GetSenseAsync(string senseId, string etag = null, CancellationToken cancellationToken = default) { @@ -156,6 +170,23 @@ public async Task GetSenseAsync(string senseId, string etag = null, Cance return responseObject; } + /// + public async Task GetSenseWithTranslationsAsync(string senseId, string targetLanguage, string etag = null, CancellationToken cancellationToken = default) + { + ValidateLanguageCode(targetLanguage, nameof(targetLanguage)); + + var sense = await GetSenseAsync(senseId, etag, cancellationToken); + await _translationEnricher.EnrichSenseTranslationsAsync( + sense, + sourceText: sense.Definition, + sourceLanguage: null, + targetLanguage, + TranslateToForEnrichmentAsync, + TranslateExampleForEnrichmentAsync, + cancellationToken); + return sense; + } + /// public Task SearchDefinitionsAsync(string searchText, string language = null, string etag = null, CancellationToken cancellationToken = default) { @@ -167,7 +198,7 @@ public Task SearchDefinitionsAsync(string searchText, string lan if (!string.IsNullOrEmpty(language)) { ValidateLanguageCode(language, nameof(language)); - query += $"&lang={Uri.EscapeDataString(language)}"; + query += $"&language={Uri.EscapeDataString(language)}"; } return ExecuteSearch(query, etag, cancellationToken); @@ -190,6 +221,42 @@ public Task FlukySearchAsync(string source = "global", string la return ExecuteSearch(query, etag, cancellationToken); } + /// + public Task TranslateToAsync(string text, string targetLanguage, string language = null, string etag = null, CancellationToken cancellationToken = default) + { + var query = BuildTranslationQuery(Constants.TranslateTo, text, targetLanguage, language); + return ExecuteTranslationQuery(query, etag, cancellationToken); + } + + /// + public Task TranslateExampleAsync(string text, string targetLanguage, string language = null, string etag = null, CancellationToken cancellationToken = default) + { + var query = BuildTranslationQuery(Constants.TranslateExample, text, targetLanguage, language); + return ExecuteTranslationQuery(query, etag, cancellationToken); + } + + /// + public Task TranslatePhraseAsync(string text, string targetLanguage, string language = null, string etag = null, CancellationToken cancellationToken = default) + { + var query = BuildTranslationQuery(Constants.TranslatePhrase, text, targetLanguage, language); + return ExecuteTranslationQuery(query, etag, cancellationToken); + } + + private static string BuildTranslationQuery(string endpoint, string text, string targetLanguage, string language) + { + ArgumentException.ThrowIfNullOrEmpty(text, nameof(text)); + ValidateLanguageCode(targetLanguage, nameof(targetLanguage)); + + var query = $"{endpoint}?target_language={Uri.EscapeDataString(targetLanguage)}&text={Uri.EscapeDataString(text)}"; + if (!string.IsNullOrEmpty(language)) + { + ValidateLanguageCode(language, nameof(language)); + query += $"&language={Uri.EscapeDataString(language)}"; + } + + return query; + } + private static string BuildAdvancedSearchQueryString(string endpoint, AdvancedSearchRequest searchRequest) { var queryParameters = new List> @@ -316,6 +383,21 @@ private async Task ExecuteSearch(string querystring, string etag return responseObject; } + private async Task ExecuteTranslationQuery(string querystring, string etag, CancellationToken cancellationToken) + { + using var response = await ExecuteRequestAsync(HttpMethod.Get, querystring, etag, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var responseObject = JsonSerializer.Deserialize(content, JsonSerializerDefaults.Options) ?? new TranslationResponse(); + responseObject.Metadata = GetResponseMetadata(response.Headers); + return responseObject; + } + + private Task TranslateToForEnrichmentAsync(string text, string targetLanguage, string sourceLanguage, CancellationToken cancellationToken) + => TranslateToAsync(text, targetLanguage, sourceLanguage, cancellationToken: cancellationToken); + + private Task TranslateExampleForEnrichmentAsync(string text, string targetLanguage, string sourceLanguage, CancellationToken cancellationToken) + => TranslateExampleAsync(text, targetLanguage, sourceLanguage, cancellationToken: cancellationToken); + private static SearchResponse DeserializeSearchResponse(string content) { var responseObject = JsonSerializer.Deserialize(content, JsonSerializerDefaults.Options); @@ -439,7 +521,29 @@ private async Task CreateApiExceptionAsync(HttpResponseMes var content = await response.Content.ReadAsStringAsync(cancellationToken); var message = GetErrorMessageFromContent(content) ?? response.ReasonPhrase ?? "An error occurred while calling the Lexicala API."; - _logger.LogError("API request failed with status {StatusCode}. Error message: {Message}", response.StatusCode, message); + if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + var resetSeconds = -1; + if (response.Headers.TryGetValues(ResponseHeaders.HeaderRateLimitReset, out var resetValues)) + { + int.TryParse(resetValues.FirstOrDefault(), out resetSeconds); + } + + if (resetSeconds > 0) + { + _logger.LogError( + "API rate limit exceeded (HTTP 429). Quota resets in {ResetSeconds}s. Request failed without retrying because the reset time exceeds the retry threshold.", + resetSeconds); + } + else + { + _logger.LogError("API rate limit exceeded (HTTP 429). No reset time available in response headers."); + } + } + else + { + _logger.LogError("API request failed with status {StatusCode}. Error message: {Message}", response.StatusCode, message); + } return new LexicalaApiException(message, response.StatusCode, content, GetResponseMetadata(response.Headers)); } diff --git a/source/Lexicala.NET/Response/Entries/Sense.cs b/source/Lexicala.NET/Response/Entries/Sense.cs index 0ee6d3b..b7600b5 100644 --- a/source/Lexicala.NET/Response/Entries/Sense.cs +++ b/source/Lexicala.NET/Response/Entries/Sense.cs @@ -48,6 +48,15 @@ public class Sense [JsonPropertyName("translations")] public Dictionary Translations { get; set; } = []; + /// + /// Gets or sets the list of language codes for which translations are available (lite endpoint). + /// + /// + /// Populated by the lite entry endpoints instead of full objects. + /// + [JsonPropertyName("available_translations")] + public string[] AvailableTranslations { get; set; } = []; + /// /// Gets or sets usage examples for this sense. /// diff --git a/source/Lexicala.NET/Response/Search/HeadwordObjectConverter.cs b/source/Lexicala.NET/Response/Search/HeadwordObjectConverter.cs index d21b17e..91887be 100644 --- a/source/Lexicala.NET/Response/Search/HeadwordObjectConverter.cs +++ b/source/Lexicala.NET/Response/Search/HeadwordObjectConverter.cs @@ -16,6 +16,8 @@ public override HeadwordObject Read(ref Utf8JsonReader reader, Type typeToConver case JsonTokenType.StartArray: var arrayValue = JsonSerializer.Deserialize(ref reader, options); return new HeadwordObject { HeadwordElementArray = arrayValue }; + case JsonTokenType.String: + return new HeadwordObject { Headword = new Headword { Text = reader.GetString() } }; } throw new JsonException("Cannot unmarshal type HeadwordObject"); diff --git a/source/Lexicala.NET/Response/Search/Result.cs b/source/Lexicala.NET/Response/Search/Result.cs index d3ccbd1..cbeaa82 100644 --- a/source/Lexicala.NET/Response/Search/Result.cs +++ b/source/Lexicala.NET/Response/Search/Result.cs @@ -8,12 +8,24 @@ public class Result [JsonPropertyName("id")] public string Id { get; set; } + [JsonPropertyName("entry_id")] + public string EntryId { get; set; } + [JsonPropertyName("language")] public string Language { get; set; } [JsonPropertyName("headword")] public HeadwordObject Headword { get; set; } + [JsonPropertyName("pos")] + public string Pos { get; set; } + + [JsonPropertyName("sense_id")] + public string SenseId { get; set; } + + [JsonPropertyName("definition")] + public string Definition { get; set; } + [JsonPropertyName("senses")] public Sense[] Senses { get; set; } = []; } diff --git a/source/Lexicala.NET/Response/Translation/TranslationResponse.cs b/source/Lexicala.NET/Response/Translation/TranslationResponse.cs new file mode 100644 index 0000000..a775553 --- /dev/null +++ b/source/Lexicala.NET/Response/Translation/TranslationResponse.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Lexicala.NET.Response.Translation +{ + /// + /// Represents a translation endpoint response payload with raw result objects. + /// + /// + /// Translation endpoint result shapes can vary by endpoint and source resource. + /// Raw values are exposed so callers can parse as needed. + /// + public class TranslationResponse + { + /// + /// Gets or sets the total number of matching results. + /// + [JsonPropertyName("n_results")] + public int NResults { get; set; } + + /// + /// Gets or sets the current page number. + /// + [JsonPropertyName("page_number")] + public int PageNumber { get; set; } + + /// + /// Gets or sets the number of results per page. + /// + [JsonPropertyName("results_per_page")] + public int ResultsPerPage { get; set; } + + /// + /// Gets or sets the total number of pages. + /// + [JsonPropertyName("n_pages")] + public int NPages { get; set; } + + /// + /// Gets or sets the number of pages available for retrieval. + /// + [JsonPropertyName("available_n_pages")] + public int AvailableNPages { get; set; } + + /// + /// Gets or sets raw endpoint result objects. + /// + [JsonPropertyName("results")] + public JsonElement[] Results { get; set; } = []; + + /// + /// Gets or sets response header metadata (ETag and rate limits). + /// + public ResponseMetadata Metadata { get; set; } = new ResponseMetadata(); + } +} diff --git a/source/Lexicala.NET/Translation/TranslationEnricher.cs b/source/Lexicala.NET/Translation/TranslationEnricher.cs new file mode 100644 index 0000000..aa0a33d --- /dev/null +++ b/source/Lexicala.NET/Translation/TranslationEnricher.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Response.Entries; +using Lexicala.NET.Response.Translation; +using Sense = Lexicala.NET.Response.Entries.Sense; + +namespace Lexicala.NET.Internal +{ + /// + /// Enriches lite entry/sense payloads with translations fetched from translation endpoints. + /// + internal sealed class TranslationEnricher + { + public async Task EnrichEntryTranslationsAsync( + Entry entry, + string targetLanguage, + Func> translateToAsync, + Func> translateExampleAsync, + CancellationToken cancellationToken) + { + if (entry?.Senses is null || entry.Senses.Length == 0) + { + return; + } + + var sourceText = entry.Headwords.FirstOrDefault()?.Text; + var sourceLanguage = entry.Language; + + foreach (var sense in entry.Senses) + { + await EnrichSenseTranslationsAsync( + sense, + sourceText, + sourceLanguage, + targetLanguage, + translateToAsync, + translateExampleAsync, + cancellationToken); + } + } + + public async Task EnrichSenseTranslationsAsync( + Sense sense, + string sourceText, + string sourceLanguage, + string targetLanguage, + Func> translateToAsync, + Func> translateExampleAsync, + CancellationToken cancellationToken) + { + if (sense is null) + { + return; + } + + if (!HasTranslationForTarget(sense.Translations, targetLanguage) && !string.IsNullOrWhiteSpace(sourceText)) + { + var translation = await translateToAsync(sourceText, targetLanguage, sourceLanguage, cancellationToken); + var translatedText = TryExtractBestTranslationText(translation, sourceText); + if (!string.IsNullOrWhiteSpace(translatedText)) + { + sense.Translations ??= []; + sense.Translations[targetLanguage] = new TranslationObject { Translation = new Translation { Text = translatedText } }; + } + } + + if (sense.Examples is null || sense.Examples.Length == 0) + { + return; + } + + foreach (var example in sense.Examples) + { + if (string.IsNullOrWhiteSpace(example?.Text) || HasTranslationForTarget(example.Translations, targetLanguage)) + { + continue; + } + + var translation = await translateExampleAsync(example.Text, targetLanguage, sourceLanguage, cancellationToken); + var translatedText = TryExtractBestTranslationText(translation, example.Text); + if (string.IsNullOrWhiteSpace(translatedText)) + { + continue; + } + + example.Translations ??= []; + example.Translations[targetLanguage] = new TranslationObject { Translation = new Translation { Text = translatedText } }; + } + } + + private static bool HasTranslationForTarget(Dictionary translations, string targetLanguage) + { + if (translations is null || !translations.TryGetValue(targetLanguage, out var translationObject)) + { + return false; + } + + return !string.IsNullOrWhiteSpace(translationObject.Translation?.Text) + || translationObject.Translations?.Any(t => !string.IsNullOrWhiteSpace(t.Text)) == true; + } + + private static string TryExtractBestTranslationText(TranslationResponse response, string sourceText) + { + if (response?.Results is null) + { + return null; + } + + foreach (var result in response.Results) + { + var candidate = TryExtractTranslationText(result, sourceText); + if (!string.IsNullOrWhiteSpace(candidate)) + { + return candidate; + } + } + + return null; + } + + private static string TryExtractTranslationText(JsonElement element, string sourceText) + { + if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty("text", out var textElement) && textElement.ValueKind == JsonValueKind.String) + { + var text = textElement.GetString(); + if (!string.IsNullOrWhiteSpace(text) && !string.Equals(text, sourceText, StringComparison.OrdinalIgnoreCase)) + { + return text; + } + } + + foreach (var property in element.EnumerateObject()) + { + var nested = TryExtractTranslationText(property.Value, sourceText); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + var nested = TryExtractTranslationText(item, sourceText); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + } + + return null; + } + } +}