diff --git a/.github/instructions/repository-information.instructions.md b/.github/instructions/repository-information.instructions.md new file mode 100644 index 0000000..1a9a755 --- /dev/null +++ b/.github/instructions/repository-information.instructions.md @@ -0,0 +1,34 @@ +--- +name: repository-information +description: "Use when referring to repository structure, project layout, or overall information about the Lexicala.NET repo." +applyTo: "**/*" +--- + +This repository contains the Lexicala.NET client library and related projects. + +Official documentation: https://api.lexicala.com/documentation/ + +Key repository information: + +- Solution: `source/Lexicala.NET.slnx` +- Projects: + - `source/Lexicala.NET/` — main library project with the Lexicala client, configuration, parsing, request, and response types + - `source/Demo/Lexicala.NET.Demo.Api/` — ASP.NET Core host with Swagger UI for manually exercising the implemented Lexicala endpoints + - `source/Lexicala.NET.Tests/` — unit tests and parser tests with embedded JSON fixtures under `Resources/` +- Target frameworks: + - `source/Lexicala.NET/` — `net8.0` and `net10.0` + - `source/Demo/Lexicala.NET.Demo.Api/` — `net10.0` + - `source/Lexicala.NET.Tests/` — `net10.0` +- Purpose: provides a .NET SDK for interacting with the Lexicala API, including request models, response models, parser/search abstractions, dependency registration, and a Swagger-enabled local test host. +- Repository is organized into: + - `Parsing/` for search parser implementation and DTO models + - `Request/` for request model definitions + - `Response/` for response model definitions and metadata + - `.github/workflows/` for CI, PR validation, and package publishing workflows + +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. +- 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/build-on-push.yml b/.github/workflows/build-on-push.yml index fd1fe7e..f39e0f7 100644 --- a/.github/workflows/build-on-push.yml +++ b/.github/workflows/build-on-push.yml @@ -2,9 +2,9 @@ name: Build & Test on: push: - paths-ignore: '**.md' + paths-ignore: ['**.md'] # don't run on master because we already have another action for that - branches-ignore: master + branches-ignore: [master] jobs: build: @@ -12,14 +12,14 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core 8 - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: '10.0.x' - name: Install dependencies - run: dotnet restore ./source/Lexicala.NET.sln + run: dotnet restore ./source/Lexicala.NET.slnx - name: Build - run: dotnet build ./source/Lexicala.NET.sln --configuration Release --no-restore + run: dotnet build ./source/Lexicala.NET.slnx --configuration Release --no-restore - name: Test - run: dotnet test ./source/Lexicala.NET.sln --no-restore --verbosity normal + run: dotnet test ./source/Lexicala.NET.slnx --no-restore --verbosity normal diff --git a/.github/workflows/build-test_pull-request.yml b/.github/workflows/build-test_pull-request.yml index fff9ad4..f37afba 100644 --- a/.github/workflows/build-test_pull-request.yml +++ b/.github/workflows/build-test_pull-request.yml @@ -10,14 +10,14 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Install dependencies - run: dotnet restore ./source/Lexicala.NET.sln + run: dotnet restore ./source/Lexicala.NET.slnx - name: Build - run: dotnet build ./source/Lexicala.NET.sln --configuration Release --no-restore + run: dotnet build ./source/Lexicala.NET.slnx --configuration Release --no-restore - name: Test - run: dotnet test ./source/Lexicala.NET.sln --no-restore --verbosity normal + run: dotnet test ./source/Lexicala.NET.slnx --no-restore --verbosity normal diff --git a/.github/workflows/package-Ms_DI.yml b/.github/workflows/package-Ms_DI.yml deleted file mode 100644 index 42b755c..0000000 --- a/.github/workflows/package-Ms_DI.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Build and Push to Nuget - MicrosoftDependencyInjection - -on: - push: - branches: [ master ] - paths-ignore: '**.md' - -jobs: - build: - - runs-on: windows-latest - - steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core 7 - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 7.0.x - - name: Install dependencies - run: dotnet restore ./source/Lexicala.NET.sln - - name: Build - run: dotnet build ./source/Lexicala.NET.sln --configuration Release --no-restore - - name: Test - run: dotnet test ./source/Lexicala.NET.sln --no-restore --verbosity normal - - name: Publish to Nuget - uses: alirezanet/publish-nuget@v3.0.4 - with: - PROJECT_FILE_PATH: ./source/Lexicala.NET.MicrosoftDependencyInjection/Lexicala.NET.MicrosoftDependencyInjection.csproj - NUGET_KEY: ${{secrets.NUGET_API_KEY}} diff --git a/.github/workflows/package-autofac.yml b/.github/workflows/package-autofac.yml deleted file mode 100644 index ba9b917..0000000 --- a/.github/workflows/package-autofac.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Build and Push to Nuget - Autofac - -on: - push: - branches: [ master ] - paths-ignore: '**.md' - -jobs: - build: - - runs-on: windows-latest - - steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core 7 - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 7.0.x - - name: Install dependencies - run: dotnet restore ./source/Lexicala.NET.sln - - name: Build - run: dotnet build ./source/Lexicala.NET.sln --configuration Release --no-restore - - name: Test - run: dotnet test ./source/Lexicala.NET.sln --no-restore --verbosity normal - - name: Publish to Nuget - uses: alirezanet/publish-nuget@v3.0.4 - with: - PROJECT_FILE_PATH: ./source/Lexicala.NET.Autofac/Lexicala.NET.Autofac.csproj - NUGET_KEY: ${{secrets.NUGET_API_KEY}} diff --git a/.github/workflows/package-main.yml b/.github/workflows/package-main.yml index e36629d..5b0acd7 100644 --- a/.github/workflows/package-main.yml +++ b/.github/workflows/package-main.yml @@ -3,7 +3,7 @@ name: Build Test Package on: push: branches: [ master ] - paths-ignore: '**.md' + paths-ignore: ['**.md'] jobs: build: @@ -11,17 +11,17 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v2 - - name: Setup .NET Core 8 - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Install dependencies - run: dotnet restore ./source/Lexicala.NET.sln + run: dotnet restore ./source/Lexicala.NET.slnx - name: Build - run: dotnet build ./source/Lexicala.NET.sln --configuration Release --no-restore + run: dotnet build ./source/Lexicala.NET.slnx --configuration Release --no-restore - name: Test - run: dotnet test ./source/Lexicala.NET.sln --no-restore --verbosity normal + run: dotnet test ./source/Lexicala.NET.slnx --no-restore --verbosity normal - name: Publish to Nuget uses: alirezanet/publish-nuget@v3.0.4 with: diff --git a/.gitignore b/.gitignore index 92e62cc..7e570c6 100644 --- a/.gitignore +++ b/.gitignore @@ -360,6 +360,7 @@ MigrationBackup/ # End of https://www.toptal.com/developers/gitignore/api/visualstudio -/source/Lexicala.NET.ConsoleApp/appsettings.json -/source/Lexicala.NET.ConsoleApp.NET472/App.config /source/Visual Studio 2022/Visualizers +# ignnore local Snyk files +.vscode/settings.json +.vscode/diff/vulsCount.txt diff --git a/README.md b/README.md index 2ed4f1c..d52b5bc 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,353 @@ ![Main package](https://github.com/HannoZ/Lexicala.NET/workflows/Build%20Test%20Package/badge.svg) # Lexicala.NET -A .NET Client for the Lexicala dictionary api. The Lexical dictionary api is hosted by RapidApi, you need to register an account at RapidApi and obtain an api key in order to use this api. -## About the repository -The repository contains the .NET implementation for (most parts of) the Lexicala Api. It also contains parser logic that implements and uses the ILexicalaClient to execute a search request and parse the results into a model that is easier to use than the raw data from the api (at least for me it is ;-) ). For full documentation on the api visit the [Lexicala documentation page](https://api.lexicala.com/documentation). +A modern .NET client library for the Lexicala Dictionary API. Lexicala provides comprehensive linguistic data including translations, definitions, pronunciations, and more across multiple languages. -All api methods, except for `/senses` are implemented, but not all methods are thoroughly tested. I have started to build this library for a hobby project where I only need translation from Spanish to some other languages, but I have tested some searches on English words because they can have a much more extensive response. +## Prerequisites -A basic search query can be executed by specifying the search text and source language. The response object is as complete as possible by trying out many different search queries, but it could be that some properties are still missing. -Advanced search queries can also be executed. +- .NET 8.0 or .NET 10.0 runtime +- A RapidAPI account with an API key for Lexicala (sign up at [RapidAPI](https://rapidapi.com/)) -Implemented api methods: -- `/test` -- `/users/me` -- `/languages` -- `/search` (two implementations, basic and advanced) -- `/entries` +## Installation -Entries is the most interesting part of the api because it contains the detailed information on a search result ('sense'). As with the search response, I've tried to have the response as complete as possible. +Install the package via NuGet: +```bash +dotnet add package Lexicala.NET +``` + +Or using the Package Manager Console: + +```powershell +Install-Package Lexicala.NET +``` + +## Configuration + +### 1. Obtain API Key + +1. Sign up for a RapidAPI account at [rapidapi.com](https://rapidapi.com/) +2. Subscribe to the [Lexicala API](https://rapidapi.com/lexicala/api/lexicala/) +3. Copy your API key from the RapidAPI dashboard + +### 2. Configure Your Application + +Add the Lexicala configuration to your `appsettings.json`: -## Basic usage -Lexicala.NET is available on Nuget. -An extension method is available to register the ILexicalaClient and other dependencies in a .NET Core startup class: -`services.RegisterLexicala(Configuration)` -This method depends on a Lexicala section in your appsettings.json file: ```json { -"Lexicala": { - "ApiKey": "my-key" + "Lexicala": { + "ApiKey": "your-rapidapi-key-here" } } ``` -Now you can either inject and use the ILexicalaClient directly, or use the ILexicalaSearchParser. - -## Code examples -````c# -// get available languages in the Global dictionary -var response = await lexicalaClient.LanguagesAsync(); -languages = response.Resources.Global; - -// execute a basic search using the Lexicala client for the word árbol in spanish -var searchTerm = "árbol"; -var srcLang = "en"; -var searchResponse = await lexicalaClient.BasicSearchAsync(searchTerm, srcLang); + +### 3. Register Services + +In your `Program.cs` (for .NET 6+): + +```csharp +using Lexicala.NET; + +var builder = WebApplication.CreateBuilder(args); + +// Register Lexicala services +builder.Services.RegisterLexicala(builder.Configuration); + +var app = builder.Build(); +// ... rest of your setup +``` + +## Supported Frameworks + +- .NET 8.0 +- .NET 10.0 + +## Getting Started + +After configuration, you can inject `ILexicalaClient` or `ILexicalaSearchParser` into your services. + +### Basic Search Example + +```csharp +// Inject ILexicalaClient +public class TranslationService +{ + private readonly ILexicalaClient _client; + + public TranslationService(ILexicalaClient client) + { + _client = client; + } + + public async Task TranslateWordAsync(string word, string fromLang, string toLang) + { + var searchResponse = await _client.BasicSearchAsync(word, fromLang); + if (searchResponse.Results.Any()) + { + var entry = await _client.GetEntryAsync(searchResponse.Results.First().Id); + // Translations is Dictionary keyed by 2-letter language code + var sense = entry.Senses.FirstOrDefault(s => s.Translations.ContainsKey(toLang)); + if (sense is not null && sense.Translations.TryGetValue(toLang, out var translationObj)) + { + return translationObj.Translation?.Text + ?? translationObj.Translations?.FirstOrDefault()?.Text + ?? "Translation not found"; + } + } + return "Word not found"; + } +} +``` + +## Code Examples + +### Get Available Languages + +```csharp +var languagesResponse = await lexicalaClient.LanguagesAsync(); +var globalLanguages = languagesResponse.Resources.Global; +Console.WriteLine($"Available languages: {string.Join(", ", globalLanguages.SourceLanguages)}"); +``` + +### Basic Search + +```csharp +// Search for "hello" in English +var searchResponse = await lexicalaClient.BasicSearchAsync("hello", "en"); foreach (var result in searchResponse.Results) { - // get the entry details + var headwordText = + result.Headword?.Headword?.Text + ?? result.Headword?.HeadwordElementArray?.FirstOrDefault()?.Text + ?? "(no headword)"; + + Console.WriteLine($"Found: {headwordText} (ID: {result.Id})"); +} +``` + +### Advanced Search + +```csharp +var advancedRequest = new AdvancedSearchRequest +{ + Language = "en", + SearchText = "run", + Pos = "verb" // Part of speech filter +}; + +var advancedResponse = await lexicalaClient.AdvancedSearchAsync(advancedRequest); +foreach (var result in advancedResponse.Results) +{ var entry = await lexicalaClient.GetEntryAsync(result.Id); - foreach (var sense in entry.Senses) + // Process detailed entry information +} +``` + +### Using the Search Parser + +The `ILexicalaSearchParser` provides a higher-level abstraction for easier parsing: + +```csharp +// Inject ILexicalaSearchParser +public class SearchService +{ + private readonly ILexicalaSearchParser _parser; + + public SearchService(ILexicalaSearchParser parser) + { + _parser = parser; + } + + public async Task SearchAndParseAsync(string term, string language) { - // the sense contains all the information + return await _parser.SearchAsync(term, language); } } -// use the LexicalaSearchParser to search for árbol in spanish -string searchTerm = "árbol"; -string srcLang = "es"; +// Usage +var result = await searchService.SearchAndParseAsync("árbol", "es"); +var englishSummary = result.Summary("en"); // "tree, shaft, post, mast" +foreach (var searchResult in result.Results) +{ + var definition = searchResult.Senses.FirstOrDefault()?.Definition; + Console.WriteLine($"Definition: {definition}"); +} +``` -var resultModel = await lexicalaSearchParser.SearchAsync(searchTerm, srcLang); -var summary_en = resultModel.Summary("en"); // "tree, shaft, post, mast" +### Get Entry Details -foreach(var result in resultModel.Results) +```csharp +var entry = await lexicalaClient.GetEntryAsync("EN00001234"); // Example ID +foreach (var sense in entry.Senses) { - // do something with result - string definition = result.Senses.First().Definition; // "planta de tronco leñoso y elevado" + Console.WriteLine($"Sense: {sense.Definition}"); } -```` +foreach (var headword in entry.Headwords) +{ + foreach (var pron in headword.Pronunciations) + { + Console.WriteLine($"Pronunciation: {pron.Value}"); + } +} +``` + +## Testing with Swagger UI + +The repository includes an ASP.NET Core minimal Web API demo host with Swagger UI for testing all endpoints. + +1. Clone the repository and navigate to the demo API: + + ```bash + cd source/Demo/Lexicala.NET.Demo.Api + ``` + +2. Run the application: + + ```bash + dotnet run + ``` + +3. Open Swagger UI in your browser: + - HTTP: `http://localhost:5000/swagger` + - HTTPS: `https://localhost:5001/swagger` + +4. Test the endpoints directly in the UI. + +5. To use the Sense Sprint web app, run the dedicated frontend described in the "Sense Sprint Frontend (Vite + React)" section below. + +Available endpoints: + +- `GET /test` - Test API connectivity +- `GET /languages` - Get available languages +- `GET /search` - Basic search +- `GET /search-entries` - Basic search with full entries +- `GET /search-rdf` - Basic search in RDF/JSON-LD format +- `GET /search-definitions` - Free-text search in definitions +- `GET /fluky-search` - Random word discovery +- `GET /entry/{entryId}` - Get dictionary entry by ID +- `GET /sense/{senseId}` - Get sense by ID +- `GET /rdf/{entryId}` - Get entry in RDF/JSON-LD format +- `POST /search-advanced` - Advanced search +- `POST /search-entries-advanced` - Advanced search with full entries +- `POST /search-rdf-advanced` - Advanced search in RDF/JSON-LD format +- `POST /game/sense-sprint/rounds` - Create a new Sense Sprint round (word sourced from Fluky Search, language fixed to English) +- `POST /game/sense-sprint/rounds/{roundId}/clues/next` - Reveal the next clue for the active round +- `POST /game/sense-sprint/rounds/{roundId}/guess` - Submit a guess for the round + +For React frontend development, CORS is enabled for: + +- `http://localhost:3000` +- `http://localhost:5173` + +## Sense Sprint Frontend (Vite + React) + +A dedicated React frontend is available at `source/Demo/sense-sprint-web`. + +1. Start the backend host: + + ```bash + dotnet run --project source/Demo/Lexicala.NET.Demo.Api/Lexicala.NET.Demo.Api.csproj + ``` + +2. In another terminal, navigate to the frontend and install dependencies (required once): + + **PowerShell:** + + ```powershell + cd source/Demo/sense-sprint-web + npm.cmd install + ``` + + **Bash / Command Prompt:** + + ```bash + cd source/Demo/sense-sprint-web + npm install + ``` + +3. Start the frontend dev server: + + **PowerShell:** + + ```powershell + cd source/Demo/sense-sprint-web + npm.cmd run dev + ``` + + **Bash / Command Prompt:** + + ```bash + cd source/Demo/sense-sprint-web + npm run dev + ``` + +4. Open the app at: + - `http://localhost:5173` + +The Vite dev server proxies `/game/*` calls to `http://localhost:5000`, so the game endpoints work without extra CORS setup. + +**Note:** PowerShell requires `npm.cmd` due to execution policies. If you see "npm is not recognized", ensure you're using `npm.cmd run dev` (PowerShell) or open Command Prompt / Git Bash instead. + +## Building from Source + +1. Clone the repository: + + ```bash + git clone https://github.com/HannoZ/Lexicala.NET.git + cd Lexicala.NET + ``` + +2. Build the solution: + + ```bash + dotnet build source/Lexicala.NET.slnx + ``` + +3. Run tests: + + ```bash + dotnet test source/Lexicala.NET.slnx + ``` + +The legacy `source/Lexicala.NET.sln` file has been removed in favor of `source/Lexicala.NET.slnx`. + +## API Coverage + +The library implements the following Lexicala API endpoints: + +**Utility Endpoints** + +- `/test` - Test API connectivity +- `/languages` - Get available languages + +**Search Endpoints** + +- `/search` - Basic search +- `/search-entries` - Search with full entries +- `/search-rdf` - Search in RDF/JSON-LD format +- `/search-definitions` - Free-text search in definitions +- `/fluky-search` - Random word discovery + +**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** + +- `/entries` - Get entry details by ID +- `/senses` - Get sense details by ID +- `/rdf` - Get entry in RDF/JSON-LD format + +For complete API documentation, visit the [Lexicala API Documentation](https://api.lexicala.com/documentation). + +## Contributing + +Contributions are welcome! Please feel free to submit issues and pull requests. + +## License -## TODO -- improve exception handling -- implement sense api +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/changelog.md b/changelog.md index a94fd10..3b00309 100644 --- a/changelog.md +++ b/changelog.md @@ -1,16 +1,19 @@ -# Change log +# Change log ## Lexicala.NET -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 his been fixed by using the new RapidApi url now. + +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. 1.7.0 Added support for .NET 7 1.6.1 Package updates. Now supports .NET 6.0 and .NET standard 2.0 -1.6.0.1 - Fixed some issues that could cause null reference exceptions; Added XML documentation for all public members; Updated dependencies. This version is now compatible with .NET 6. +1.6.0.1 - Fixed some issues that could cause null reference exceptions; Added XML documentation for all public members; Updated dependencies. This version is now compatible with .NET 6. 1.5 - As an updated to version 1.4, an overload was added to `ILexicalaSearchParser` to allow advanced search parameters. For example searching for 'sin embargo' requires parameter '`analysed=true`' in order to return results -1.4 - Implemented 'composite phrases' parsing, this has now been added to the parsed search result. +1.4 - Implemented 'composite phrases' parsing, this has now been added to the parsed search result. 1.3 - Updated to .NET 5 packages @@ -18,9 +21,9 @@ 1.1.1. - Fixed a bug in the LexicalaSearchParser that caused the Pos property of a SearchResultEntry to display System.String[] instead of the actual value -1.1 - The DependencyRegistration helper has been moved to a new project/package: Lexicala.NET.MicrosoftDependencyInjection. Also an integration package for Autofac is now available. The `LexicalaConfig` class has been extended with helper methods to setup the LexicalaClient. The Microsoft and Autofac integration packages make use of those helper methods. +1.1 - The DependencyRegistration helper has been moved to a new project/package: Lexicala.NET.MicrosoftDependencyInjection. Also an integration package for Autofac is now available. The `LexicalaConfig` class has been extended with helper methods to setup the LexicalaClient. The Microsoft and Autofac integration packages make use of those helper methods. -1.0 - The Client and Parser project have been merged into a single project and Nuget package. +1.0 - The Client and Parser project have been merged into a single project and Nuget package. - `Lexicala.NET.Client` namespaces are changed to `Lexicala.NET` - `Lexicala.NET.Parser` namespaces are changed to `Lexicala.NET.Parsing` diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/ISenseSprintGameService.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/ISenseSprintGameService.cs new file mode 100644 index 0000000..a418310 --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/ISenseSprintGameService.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Lexicala.NET.Demo.Api.Game; + +public interface ISenseSprintGameService +{ + Task CreateRoundAsync(CancellationToken cancellationToken = default); + + Task RevealNextClueAsync(Guid roundId, CancellationToken cancellationToken = default); + + Task SubmitGuessAsync(Guid roundId, string guess, CancellationToken cancellationToken = default); + + Task GiveUpAsync(Guid roundId, CancellationToken cancellationToken = default); +} diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintContracts.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintContracts.cs new file mode 100644 index 0000000..221d235 --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintContracts.cs @@ -0,0 +1,41 @@ +using System; + +namespace Lexicala.NET.Demo.Api.Game; + +public sealed record CreateRoundResponse( + Guid RoundId, + DateTimeOffset ExpiresAtUtc, + int ClueIndex, + string Clue, + int ScoreIfCorrect, + int MaxClues, + int RoundSeconds, + RateLimitDebugResponse? RateLimit +); + +public sealed record RateLimitDebugResponse( + int Limit, + int LimitRemaining, + long ResetSeconds +); + +public sealed record NextClueResponse( + Guid RoundId, + DateTimeOffset ExpiresAtUtc, + int ClueIndex, + string Clue, + int ScoreIfCorrect, + int MaxClues +); + +public sealed record GuessRequest(string Guess); + +public sealed record GuessResponse( + Guid RoundId, + bool IsCorrect, + string RoundStatus, + int AwardedPoints, + int CurrentClueIndex, + string? CorrectAnswer, + string? Message +); diff --git a/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs b/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs new file mode 100644 index 0000000..a2915e3 --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Game/SenseSprintGameService.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Request; +using Lexicala.NET.Response; +using Lexicala.NET.Response.Entries; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace Lexicala.NET.Demo.Api.Game; + +public sealed class SenseSprintGameService : ISenseSprintGameService +{ + private static readonly int[] ScoresByClueIndex = [4, 3, 2, 1]; + private const int MaxGenerationAttempts = 8; + private const int RoundSeconds = 60; + + private readonly ILexicalaClient _lexicalaClient; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + public SenseSprintGameService( + ILexicalaClient lexicalaClient, + IMemoryCache cache, + ILogger logger) + { + _lexicalaClient = lexicalaClient; + _cache = cache; + _logger = logger; + } + + public async Task CreateRoundAsync(CancellationToken cancellationToken = default) + { + for (var attempt = 1; attempt <= MaxGenerationAttempts; attempt++) + { + var generated = await TryGenerateRoundAsync(cancellationToken); + if (generated is null) + { + _logger.LogDebug("Sense Sprint generation attempt {Attempt} failed quality filters", attempt); + continue; + } + + var roundId = Guid.NewGuid(); + _cache.Set(roundId, generated, generated.ExpiresAtUtc); + + return new CreateRoundResponse( + roundId, + generated.ExpiresAtUtc, + generated.CurrentClueIndex, + generated.Clues[generated.CurrentClueIndex], + ScoresByClueIndex[generated.CurrentClueIndex], + generated.Clues.Count, + RoundSeconds, + generated.RateLimit); + } + + throw new InvalidOperationException("Could not generate a playable round from Fluky Search. Try again."); + } + + public Task RevealNextClueAsync(Guid roundId, CancellationToken cancellationToken = default) + { + var round = GetRequiredRound(roundId); + EnsureRoundIsActive(roundId, round); + + if (round.CurrentClueIndex >= round.Clues.Count - 1) + { + throw new InvalidOperationException("No more clues are available for this round."); + } + + round.CurrentClueIndex += 1; + _cache.Set(roundId, round, round.ExpiresAtUtc); + + return Task.FromResult(new NextClueResponse( + roundId, + round.ExpiresAtUtc, + round.CurrentClueIndex, + round.Clues[round.CurrentClueIndex], + ScoresByClueIndex[round.CurrentClueIndex], + round.Clues.Count)); + } + + public Task SubmitGuessAsync(Guid roundId, string guess, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(guess); + + var round = GetRequiredRound(roundId); + + if (DateTimeOffset.UtcNow > round.ExpiresAtUtc) + { + _cache.Remove(roundId); + return Task.FromResult(new GuessResponse( + roundId, + false, + "expired", + 0, + round.CurrentClueIndex, + round.Answer, + "Round expired. Start a new round.")); + } + + if (round.IsCompleted) + { + return Task.FromResult(new GuessResponse( + roundId, + false, + "completed", + 0, + round.CurrentClueIndex, + round.Answer, + "Round already completed. Start a new round.")); + } + + var isCorrect = Normalize(round.Answer) == Normalize(guess); + if (isCorrect) + { + round.IsCompleted = true; + var points = ScoresByClueIndex[round.CurrentClueIndex]; + _cache.Set(roundId, round, round.ExpiresAtUtc); + return Task.FromResult(new GuessResponse( + roundId, + true, + "won", + points, + round.CurrentClueIndex, + round.Answer, + "Correct!")); + } + + var isOutOfClues = round.CurrentClueIndex >= round.Clues.Count - 1; + if (isOutOfClues) + { + round.IsCompleted = true; + _cache.Set(roundId, round, round.ExpiresAtUtc); + return Task.FromResult(new GuessResponse( + roundId, + false, + "lost", + 0, + round.CurrentClueIndex, + round.Answer, + "No clues remaining.")); + } + + return Task.FromResult(new GuessResponse( + roundId, + false, + "in-progress", + 0, + round.CurrentClueIndex, + null, + "Not quite. Ask for the next clue or guess again.")); + } + + public Task GiveUpAsync(Guid roundId, CancellationToken cancellationToken = default) + { + var round = GetRequiredRound(roundId); + EnsureRoundIsActive(roundId, round); + + round.IsCompleted = true; + _cache.Set(roundId, round, round.ExpiresAtUtc); + + return Task.FromResult(new GuessResponse( + roundId, + false, + "lost", + 0, + round.CurrentClueIndex, + round.Answer, + "Round ended. Better luck next time.")); + } + + private async Task TryGenerateRoundAsync(CancellationToken cancellationToken) + { + var fluky = await _lexicalaClient.FlukySearchAsync(Sources.Global, "en", cancellationToken: cancellationToken); + var candidate = fluky.Results.FirstOrDefault(); + if (candidate?.Id is null) + { + return null; + } + + var entry = await _lexicalaClient.GetEntryAsync(candidate.Id, cancellationToken: cancellationToken); + var answer = entry.Headwords.FirstOrDefault()?.Text; + + if (string.IsNullOrWhiteSpace(answer)) + { + return null; + } + + var sense = entry.Senses.FirstOrDefault(HasDefinition); + if (sense is null) + { + return null; + } + + var clues = BuildClues(entry, sense, answer); + if (clues.Count < 4) + { + return null; + } + + return new SenseSprintRoundState + { + Answer = answer, + Clues = clues, + CurrentClueIndex = 0, + IsCompleted = false, + ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(RoundSeconds), + RateLimit = BuildRateLimit(entry.Metadata) ?? BuildRateLimit(fluky.Metadata) + }; + } + + private static RateLimitDebugResponse? BuildRateLimit(ResponseMetadata? metadata) + { + var limits = metadata?.RateLimits; + if (limits is null) + { + return null; + } + + if (limits.Limit < 0 || limits.LimitRemaining < 0 || limits.Reset < 0) + { + return null; + } + + return new RateLimitDebugResponse(limits.Limit, limits.LimitRemaining, limits.Reset); + } + + private static List BuildClues(Entry entry, Sense sense, string answer) + { + var clues = new List(capacity: 4) + { + "Definition: " + MaskAnswer(sense.Definition, answer) + }; + + var relation = sense.Synonyms.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) + ?? sense.Antonyms.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + + if (!string.IsNullOrWhiteSpace(relation)) + { + clues.Add("Related word: " + relation); + } + else + { + var partOfSpeech = entry.Headwords + .SelectMany(x => x.PartOfSpeeches) + .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + + if (!string.IsNullOrWhiteSpace(partOfSpeech)) + { + clues.Add("Part of speech: " + partOfSpeech); + } + } + + var example = sense.Examples + .Select(x => x.Text) + .FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)); + + if (!string.IsNullOrWhiteSpace(example)) + { + clues.Add("Example: " + MaskAnswer(example, answer)); + } + + var letterHint = BuildLetterHint(answer); + clues.Add(letterHint); + + // Keep exactly four clues for stable frontend handling and deterministic scoring. + while (clues.Count < 4) + { + clues.Add(letterHint); + } + + if (clues.Count > 4) + { + clues = clues.Take(4).ToList(); + } + + return clues; + } + + private static bool HasDefinition(Sense sense) => !string.IsNullOrWhiteSpace(sense.Definition); + + private static string BuildLetterHint(string answer) + { + var trimmed = answer.Trim(); + var first = trimmed[0]; + var lettersOnly = new string(trimmed.Where(char.IsLetter).ToArray()).Length; + return $"Final hint: starts with '{char.ToUpperInvariant(first)}' and has {lettersOnly} letters."; + } + + private static string Normalize(string value) => value.Trim().ToLowerInvariant(); + + private static string MaskAnswer(string text, string answer) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + if (string.IsNullOrWhiteSpace(answer)) + { + return text; + } + + var replacement = new string('*', Math.Max(3, answer.Length)); + var escapedAnswer = Regex.Escape(answer.Trim()); + return Regex.Replace(text, escapedAnswer, replacement, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private SenseSprintRoundState GetRequiredRound(Guid roundId) + { + if (!_cache.TryGetValue(roundId, out SenseSprintRoundState? round) || round is null) + { + throw new KeyNotFoundException("Round not found. Start a new round."); + } + + return round; + } + + private void EnsureRoundIsActive(Guid roundId, SenseSprintRoundState round) + { + if (DateTimeOffset.UtcNow > round.ExpiresAtUtc) + { + _cache.Remove(roundId); + throw new InvalidOperationException("Round expired. Start a new round."); + } + + if (round.IsCompleted) + { + throw new InvalidOperationException("Round already completed. Start a new round."); + } + } + + private sealed class SenseSprintRoundState + { + public required string Answer { get; init; } + + public required List Clues { get; init; } + + public required DateTimeOffset ExpiresAtUtc { get; init; } + + public int CurrentClueIndex { get; set; } + + public bool IsCompleted { get; set; } + + public RateLimitDebugResponse? RateLimit { get; init; } + } +} diff --git a/source/Demo/Lexicala.NET.Demo.Api/Lexicala.NET.Demo.Api.csproj b/source/Demo/Lexicala.NET.Demo.Api/Lexicala.NET.Demo.Api.csproj new file mode 100644 index 0000000..e94c323 --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Lexicala.NET.Demo.Api.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + 83e2368c-8e8a-4e8e-9f3d-c00d575b6393 + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/source/Demo/Lexicala.NET.Demo.Api/Program.cs b/source/Demo/Lexicala.NET.Demo.Api/Program.cs new file mode 100644 index 0000000..f9efa56 --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/Program.cs @@ -0,0 +1,180 @@ +using Lexicala.NET; +using Lexicala.NET.Parsing; +using Lexicala.NET.Demo.Api.Game; +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Lexicala.NET.Request; +using System.Threading; +using System.Threading.Tasks; + +namespace Lexicala.NET.Demo.Api +{ + public class Program + { + public static async Task Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddCommandLine(args) + .AddUserSecrets(); + + builder.Services.RegisterLexicala(builder.Configuration); + builder.Services.AddSingleton(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddCors(options => + { + options.AddPolicy("ReactDev", policy => + { + policy + .WithOrigins("http://localhost:3000", "http://localhost:5173") + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + builder.Services.AddSwaggerGen(options => + { + options.CustomSchemaIds(type => type.FullName); + }); + builder.Services.AddLogging(cfg => cfg.AddConsole()); + + var app = builder.Build(); + + app.UseCors("ReactDev"); + app.UseDefaultFiles(); + app.UseStaticFiles(); + app.UseSwagger(); + app.UseSwaggerUI(); + + app.MapGet("/test", async (ILexicalaClient client, CancellationToken cancellationToken) => + await client.TestAsync(cancellationToken)) + .WithName("Test"); + + app.MapGet("/languages", async (ILexicalaClient client, CancellationToken cancellationToken) => + await client.LanguagesAsync(cancellationToken)) + .WithName("Languages"); + + app.MapGet("/search", async (ILexicalaClient client, string text, string language, string? etag, CancellationToken cancellationToken) => + await client.BasicSearchAsync(text, language, etag, cancellationToken)) + .WithName("BasicSearch"); + + app.MapGet("/search-entries", async (ILexicalaClient client, string text, string language, string? etag, CancellationToken cancellationToken) => + await client.SearchEntriesAsync(text, language, etag, cancellationToken)) + .WithName("SearchEntries"); + + app.MapGet("/search-rdf", async (ILexicalaClient client, string text, string language, string? etag, CancellationToken cancellationToken) => + Results.Text(await client.SearchRdfAsync(text, language, etag, cancellationToken), "application/ld+json")) + .WithName("SearchRdf"); + + app.MapGet("/entry/{entryId}", async (ILexicalaClient client, string entryId, string? etag, CancellationToken cancellationToken) => + await client.GetEntryAsync(entryId, etag, cancellationToken)) + .WithName("GetEntry"); + + app.MapGet("/sense/{senseId}", async (ILexicalaClient client, string senseId, string? etag, CancellationToken cancellationToken) => + await client.GetSenseAsync(senseId, etag, cancellationToken)) + .WithName("GetSense"); + + app.MapGet("/rdf/{entryId}", async (ILexicalaClient client, string entryId, string? etag, CancellationToken cancellationToken) => + Results.Text(await client.GetRdfAsync(entryId, etag, cancellationToken), "application/ld+json")) + .WithName("GetRdf"); + + app.MapPost("/search-advanced", async (ILexicalaClient client, AdvancedSearchRequest request, CancellationToken cancellationToken) => + await client.AdvancedSearchAsync(request, cancellationToken)) + .WithName("AdvancedSearch"); + + app.MapPost("/search-entries-advanced", async (ILexicalaClient client, AdvancedSearchRequest request, CancellationToken cancellationToken) => + await client.AdvancedSearchEntriesAsync(request, cancellationToken)) + .WithName("AdvancedSearchEntries"); + + app.MapPost("/search-rdf-advanced", async (ILexicalaClient client, AdvancedSearchRequest request, CancellationToken cancellationToken) => + Results.Text(await client.AdvancedSearchRdfAsync(request, cancellationToken), "application/ld+json")) + .WithName("AdvancedSearchRdf"); + + app.MapGet("/search-definitions", async (ILexicalaClient client, string text, string? language, string? etag, CancellationToken cancellationToken) => + await client.SearchDefinitionsAsync(text, language, etag, cancellationToken)) + .WithName("SearchDefinitions"); + + app.MapGet("/fluky-search", async (ILexicalaClient client, string? source, string? language, string? etag, CancellationToken cancellationToken) => + await client.FlukySearchAsync(source ?? "global", language, etag, cancellationToken)) + .WithName("FlukySearch"); + + app.MapPost("/game/sense-sprint/rounds", async (ISenseSprintGameService gameService, CancellationToken cancellationToken) => + { + try + { + var response = await gameService.CreateRoundAsync(cancellationToken); + return Results.Ok(response); + } + catch (InvalidOperationException ex) + { + return Results.Problem(ex.Message, statusCode: StatusCodes.Status503ServiceUnavailable); + } + }) + .WithName("SenseSprintCreateRound"); + + app.MapPost("/game/sense-sprint/rounds/{roundId:guid}/clues/next", async (ISenseSprintGameService gameService, Guid roundId, CancellationToken cancellationToken) => + { + try + { + var response = await gameService.RevealNextClueAsync(roundId, cancellationToken); + return Results.Ok(response); + } + catch (KeyNotFoundException ex) + { + return Results.NotFound(new ProblemDetails { Title = "Round not found", Detail = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new ProblemDetails { Title = "Round is not active", Detail = ex.Message }); + } + }) + .WithName("SenseSprintNextClue"); + + app.MapPost("/game/sense-sprint/rounds/{roundId:guid}/guess", async (ISenseSprintGameService gameService, Guid roundId, GuessRequest request, CancellationToken cancellationToken) => + { + try + { + var response = await gameService.SubmitGuessAsync(roundId, request.Guess, cancellationToken); + return Results.Ok(response); + } + catch (KeyNotFoundException ex) + { + return Results.NotFound(new ProblemDetails { Title = "Round not found", Detail = ex.Message }); + } + catch (ArgumentException ex) + { + return Results.BadRequest(new ProblemDetails { Title = "Invalid guess", Detail = ex.Message }); + } + }) + .WithName("SenseSprintSubmitGuess"); + + app.MapPost("/game/sense-sprint/rounds/{roundId:guid}/give-up", async (ISenseSprintGameService gameService, Guid roundId, CancellationToken cancellationToken) => + { + try + { + var response = await gameService.GiveUpAsync(roundId, cancellationToken); + return Results.Ok(response); + } + catch (KeyNotFoundException ex) + { + return Results.NotFound(new ProblemDetails { Title = "Round not found", Detail = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Results.BadRequest(new ProblemDetails { Title = "Round is not active", Detail = ex.Message }); + } + }) + .WithName("SenseSprintGiveUp"); + + await app.RunAsync(); + } + } +} diff --git a/source/Demo/Lexicala.NET.Demo.Api/appsettings.json b/source/Demo/Lexicala.NET.Demo.Api/appsettings.json new file mode 100644 index 0000000..544b7b4 --- /dev/null +++ b/source/Demo/Lexicala.NET.Demo.Api/appsettings.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/source/Demo/sense-sprint-web/.gitignore b/source/Demo/sense-sprint-web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/source/Demo/sense-sprint-web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/source/Demo/sense-sprint-web/README.md b/source/Demo/sense-sprint-web/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/source/Demo/sense-sprint-web/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/source/Demo/sense-sprint-web/eslint.config.js b/source/Demo/sense-sprint-web/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/source/Demo/sense-sprint-web/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/source/Demo/sense-sprint-web/index.html b/source/Demo/sense-sprint-web/index.html new file mode 100644 index 0000000..671c544 --- /dev/null +++ b/source/Demo/sense-sprint-web/index.html @@ -0,0 +1,13 @@ + + + + + + + sense-sprint-web + + +
+ + + diff --git a/source/Demo/sense-sprint-web/package-lock.json b/source/Demo/sense-sprint-web/package-lock.json new file mode 100644 index 0000000..0670654 --- /dev/null +++ b/source/Demo/sense-sprint-web/package-lock.json @@ -0,0 +1,2772 @@ +{ + "name": "sense-sprint-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sense-sprint-web", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/source/Demo/sense-sprint-web/package.json b/source/Demo/sense-sprint-web/package.json new file mode 100644 index 0000000..f588460 --- /dev/null +++ b/source/Demo/sense-sprint-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "sense-sprint-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.2", + "vite": "^8.0.10" + } +} diff --git a/source/Demo/sense-sprint-web/public/favicon.svg b/source/Demo/sense-sprint-web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/source/Demo/sense-sprint-web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/source/Demo/sense-sprint-web/public/icons.svg b/source/Demo/sense-sprint-web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/source/Demo/sense-sprint-web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/Demo/sense-sprint-web/src/App.css b/source/Demo/sense-sprint-web/src/App.css new file mode 100644 index 0000000..0eda6ed --- /dev/null +++ b/source/Demo/sense-sprint-web/src/App.css @@ -0,0 +1,475 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1rem 3rem; +} + +.header { + display: grid; + gap: 0.6rem; + margin-bottom: 1.2rem; + animation: slideIn 500ms ease-out; +} + +.badge { + display: inline-flex; + width: fit-content; + padding: 0.35rem 0.6rem; + border-radius: 999px; + font-family: var(--mono); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.title { + margin: 0; + font-size: clamp(2rem, 4vw, 3.2rem); + font-weight: 900; + color: #fff; + line-height: 1.03; +} + +.subtitle { + margin: 0; + max-width: 60ch; + color: #d2dae5; +} + +.panel { + background: linear-gradient(170deg, var(--panel), var(--panel-strong)); + border-radius: 20px; + padding: 1.15rem; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.6); +} + +.grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +.stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; +} + +.kpi { + background: #fff; + border-radius: 14px; + padding: 0.7rem; + border: 1px dashed #d8d8d8; +} + +.kpi-label { + margin: 0; + font-size: 0.72rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-family: var(--mono); +} + +.kpi-value { + margin: 0.28rem 0 0; + font-size: 1.2rem; + font-weight: 800; +} + +.clue { + margin-top: 0.8rem; + padding: 1rem; + border-radius: 14px; + background: #fff; + border: 1px solid #e7e7e7; + min-height: 84px; + animation: popIn 280ms ease-out; +} + +.clue-label { + margin: 0 0 0.35rem; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + font-family: var(--mono); +} + +.clue-text { + margin: 0; + font-size: 1.08rem; + line-height: 1.5; +} + +.clue-history { + margin-top: 0.8rem; + border-top: 1px dashed #d4d4d8; + padding-top: 0.65rem; +} + +.clue-history-label { + margin: 0; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + font-family: var(--mono); +} + +.clue-history-list { + margin: 0.45rem 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 0.4rem; +} + +.clue-history-list li { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem; + align-items: start; + font-size: 0.94rem; + color: #1f2937; +} + +.clue-history-index { + font-family: var(--mono); + font-size: 0.74rem; + color: #475569; + background: #f1f5f9; + border: 1px solid #dbeafe; + border-radius: 999px; + padding: 0.1rem 0.4rem; +} + +.clue-history-empty { + margin: 0.45rem 0 0; + font-size: 0.88rem; + color: #64748b; +} + +.controls { + display: grid; + grid-template-columns: 1fr; + gap: 0.7rem; + margin-top: 1rem; +} + +.input { + width: 100%; + border: 2px solid #d9d9d9; + border-radius: 12px; + padding: 0.8rem 0.9rem; + font-size: 1rem; + font-family: var(--sans); +} + +.input:focus { + outline: none; + border-color: #2f80ed; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.button { + border: 0; + cursor: pointer; + border-radius: 12px; + padding: 0.7rem 1rem; + font-weight: 800; + font-size: 0.92rem; + transition: transform 140ms ease, opacity 140ms ease; +} + +.button:hover { + transform: translateY(-1px); +} + +.button:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + +.button-primary { + background: var(--accent); + color: #fff; +} + +.button-secondary { + background: #ffe6a7; + color: #27272a; +} + +.button-warning { + background: #fecaca; + color: #7f1d1d; +} + +.button-ghost { + background: #fff; + color: #111827; + border: 1px solid #d4d4d8; +} + +.status { + margin-top: 0.65rem; + font-size: 1rem; + color: #111827; + border-radius: 12px; + padding: 0.75rem 0.85rem; + border: 1px solid #d4d4d8; + background: #f8fafc; + transition: transform 220ms ease, box-shadow 220ms ease; +} + +.status strong { + color: #111827; +} + +.status-info { + background: #eff6ff; + border-color: #bfdbfe; +} + +.status-warning { + background: #fffbeb; + border-color: #fde68a; +} + +.status-error { + background: #fef2f2; + border-color: #fecaca; +} + +.status-success { + background: linear-gradient(100deg, #ecfccb 0%, #d9f99d 35%, #ecfccb 70%); + border-color: #84cc16; + box-shadow: 0 0 0 2px rgba(132, 204, 22, 0.2); +} + +.status-win { + position: relative; + overflow: hidden; + animation: statusPulse 500ms ease-in-out infinite alternate, statusGlow 1300ms ease-in-out infinite; +} + +.status-win::before { + content: ''; + position: absolute; + inset: -30% -15%; + background: + radial-gradient(circle, rgba(254, 240, 138, 0.85) 0 6px, transparent 7px) 0 0 / 90px 90px, + radial-gradient(circle, rgba(251, 113, 133, 0.7) 0 5px, transparent 6px) 25px 35px / 120px 100px, + radial-gradient(circle, rgba(56, 189, 248, 0.72) 0 5px, transparent 6px) 60px 15px / 110px 95px; + opacity: 0.6; + pointer-events: none; + animation: confettiDrift 1700ms linear infinite; +} + +.victory-banner { + position: relative; + overflow: hidden; + margin: 0.35rem 0 0; + padding: 0.55rem 0.75rem; + border-radius: 10px; + font-family: var(--mono); + letter-spacing: 0.06em; + text-transform: uppercase; + font-size: 0.76rem; + color: #14532d; + background: repeating-linear-gradient( + -45deg, + rgba(187, 247, 208, 0.8), + rgba(187, 247, 208, 0.8) 12px, + rgba(220, 252, 231, 0.85) 12px, + rgba(220, 252, 231, 0.85) 24px + ); + border: 1px solid rgba(22, 163, 74, 0.45); + box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.25), 0 8px 20px rgba(22, 163, 74, 0.25); + animation: bannerSlide 620ms linear infinite, bannerBounce 650ms ease-in-out infinite alternate; +} + +.victory-banner::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(115deg, transparent 15%, rgba(255, 255, 255, 0.65) 50%, transparent 85%); + transform: translateX(-120%); + pointer-events: none; + animation: victorySheen 900ms ease-in-out infinite; +} + +.footer { + margin-top: 0.85rem; + color: var(--muted); + font-size: 0.86rem; + font-family: var(--mono); +} + +.debug-panel { + margin-top: 0.75rem; + border: 1px solid #d4d4d8; + border-radius: 12px; + background: #ffffff; + padding: 0.6rem 0.75rem; +} + +.debug-panel-pulse-a, +.debug-panel-pulse-b { + animation: debugPulse 450ms ease-out; +} + +.debug-panel summary { + cursor: pointer; + font-family: var(--mono); + font-size: 0.78rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #4b5563; +} + +.debug-grid { + margin-top: 0.6rem; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.45rem 0.8rem; +} + +.debug-grid p { + margin: 0; + font-family: var(--mono); + font-size: 0.76rem; + color: #334155; +} + +.error { + margin-top: 0.7rem; + color: #9f1239; + font-weight: 700; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes popIn { + from { + opacity: 0; + transform: scale(0.98); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes statusPulse { + from { + transform: translateY(0); + box-shadow: 0 0 0 2px rgba(132, 204, 22, 0.2); + } + to { + transform: translateY(-2px); + box-shadow: 0 10px 24px rgba(132, 204, 22, 0.28); + } +} + +@keyframes statusGlow { + 0% { + border-color: #84cc16; + } + 50% { + border-color: #facc15; + } + 100% { + border-color: #22c55e; + } +} + +@keyframes confettiDrift { + from { + transform: translateY(-6%) translateX(0) rotate(0deg); + } + to { + transform: translateY(6%) translateX(-5%) rotate(8deg); + } +} + +@keyframes bannerSlide { + from { + background-position: 0 0; + } + to { + background-position: 48px 0; + } +} + +@keyframes bannerBounce { + from { + transform: translateY(0) scale(1); + } + to { + transform: translateY(-3px) scale(1.015); + } +} + +@keyframes victorySheen { + from { + transform: translateX(-120%); + } + to { + transform: translateX(120%); + } +} + +@keyframes debugPulse { + from { + box-shadow: 0 0 0 0 rgba(14, 116, 144, 0.35); + border-color: #0ea5e9; + } + to { + box-shadow: 0 0 0 10px rgba(14, 116, 144, 0); + border-color: #d4d4d8; + } +} + +@media (prefers-reduced-motion: reduce) { + .status-win, + .status-win::before, + .victory-banner, + .victory-banner::after { + animation: none; + } +} + +@media (max-width: 760px) { + .page { + padding: 1.2rem 0.75rem 2rem; + } + + .stats { + grid-template-columns: 1fr; + } + + .debug-grid { + grid-template-columns: 1fr; + } +} diff --git a/source/Demo/sense-sprint-web/src/App.tsx b/source/Demo/sense-sprint-web/src/App.tsx new file mode 100644 index 0000000..be2c251 --- /dev/null +++ b/source/Demo/sense-sprint-web/src/App.tsx @@ -0,0 +1,467 @@ +import { useEffect, useMemo, useState } from 'react' +import './App.css' + +type RoundStatus = 'in-progress' | 'won' | 'lost' | 'expired' | 'completed' +type FeedbackTone = 'info' | 'success' | 'warning' | 'error' + +type RateLimitDebug = { + limit: number + limitRemaining: number + resetSeconds: number +} + +type ActiveRound = { + roundId: string + clue: string + clueIndex: number + maxClues: number + expiresAtUtc: string + scoreIfCorrect: number + roundStatus: RoundStatus + answer: string | null +} + +type CreateRoundResponse = { + roundId: string + expiresAtUtc: string + clueIndex: number + clue: string + scoreIfCorrect: number + maxClues: number + roundSeconds: number + rateLimit: RateLimitDebug | null +} + +type NextClueResponse = { + roundId: string + expiresAtUtc: string + clueIndex: number + clue: string + scoreIfCorrect: number + maxClues: number +} + +type GuessResponse = { + roundId: string + isCorrect: boolean + roundStatus: RoundStatus + awardedPoints: number + currentClueIndex: number + correctAnswer: string | null + message: string | null +} + +type ProblemResponse = { + title?: string + detail?: string +} + +type ApiResult = { + data: T + rateLimit: RateLimitDebug | null +} + +const api = { + async createRound(): Promise> { + const response = await fetch('/game/sense-sprint/rounds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + return parseResponse(response) + }, + + async nextClue(roundId: string): Promise> { + const response = await fetch(`/game/sense-sprint/rounds/${roundId}/clues/next`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + return parseResponse(response) + }, + + async guess(roundId: string, guessText: string): Promise> { + const response = await fetch(`/game/sense-sprint/rounds/${roundId}/guess`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ guess: guessText }), + }) + + return parseResponse(response) + }, + + async giveUp(roundId: string): Promise> { + const response = await fetch(`/game/sense-sprint/rounds/${roundId}/give-up`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + return parseResponse(response) + }, +} + +function getRateLimitFromHeaders(headers: Headers): RateLimitDebug | null { + const limitRaw = headers.get('X-RateLimit-requests-Limit') + const remainingRaw = headers.get('X-RateLimit-requests-Remaining') + const resetRaw = headers.get('X-RateLimit-requests-Reset') + + if (!limitRaw || !remainingRaw || !resetRaw) { + return null + } + + const limit = Number.parseInt(limitRaw, 10) + const limitRemaining = Number.parseInt(remainingRaw, 10) + const resetSeconds = Number.parseInt(resetRaw, 10) + + if (!Number.isFinite(limit) || !Number.isFinite(limitRemaining) || !Number.isFinite(resetSeconds)) { + return null + } + + return { limit, limitRemaining, resetSeconds } +} + +async function parseResponse(response: Response): Promise> { + if (!response.ok) { + const errorPayload = (await response.json().catch(() => null)) as ProblemResponse | null + const detail = errorPayload?.detail ?? errorPayload?.title ?? 'Request failed' + throw new Error(detail) + } + + const data = (await response.json()) as T + return { + data, + rateLimit: getRateLimitFromHeaders(response.headers), + } +} + +function App() { + const [round, setRound] = useState(null) + const [guess, setGuess] = useState('') + const [statusMessage, setStatusMessage] = useState('Start a round to begin.') + const [feedbackTone, setFeedbackTone] = useState('info') + const [points, setPoints] = useState(0) + const [roundsPlayed, setRoundsPlayed] = useState(0) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [timeLeft, setTimeLeft] = useState(0) + const [rateLimitDebug, setRateLimitDebug] = useState(null) + const [revealedClues, setRevealedClues] = useState([]) + const [debugCallCount, setDebugCallCount] = useState(0) + const [debugLastUpdated, setDebugLastUpdated] = useState('-') + const [debugPulseTick, setDebugPulseTick] = useState(0) + + useEffect(() => { + if (!round || round.roundStatus !== 'in-progress') { + return + } + + const timer = window.setInterval(() => { + const expires = new Date(round.expiresAtUtc).getTime() + const diff = Math.max(0, Math.floor((expires - Date.now()) / 1000)) + setTimeLeft(diff) + }, 250) + + return () => window.clearInterval(timer) + }, [round]) + + const canInteract = useMemo( + () => Boolean(round && round.roundStatus === 'in-progress' && !loading), + [round, loading], + ) + + function refreshDebug(rateLimit: RateLimitDebug | null): void { + setDebugCallCount((current) => current + 1) + setDebugLastUpdated(new Date().toLocaleTimeString()) + setDebugPulseTick((current) => current + 1) + if (rateLimit) { + setRateLimitDebug(rateLimit) + } + } + + async function startRound(): Promise { + setLoading(true) + setError('') + + try { + const createdResult = await api.createRound() + const created = createdResult.data + setRound({ + roundId: created.roundId, + clue: created.clue, + clueIndex: created.clueIndex, + maxClues: created.maxClues, + expiresAtUtc: created.expiresAtUtc, + scoreIfCorrect: created.scoreIfCorrect, + roundStatus: 'in-progress', + answer: null, + }) + setRevealedClues([created.clue]) + refreshDebug(createdResult.rateLimit ?? created.rateLimit) + + setRoundsPlayed((current) => current + 1) + setGuess('') + setStatusMessage('Round started. Read the clue and submit your best guess.') + setFeedbackTone('info') + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to start round.' + setError(message) + setFeedbackTone('error') + } finally { + setLoading(false) + } + } + + async function revealNextClue(): Promise { + if (!round) { + return + } + + setLoading(true) + setError('') + + try { + const nextResult = await api.nextClue(round.roundId) + const next = nextResult.data + setRound((current) => { + if (!current) { + return current + } + + return { + ...current, + clue: next.clue, + clueIndex: next.clueIndex, + scoreIfCorrect: next.scoreIfCorrect, + expiresAtUtc: next.expiresAtUtc, + } + }) + setRevealedClues((current) => { + if (current.includes(next.clue)) { + return current + } + + return [...current, next.clue] + }) + refreshDebug(nextResult.rateLimit) + setStatusMessage('New clue revealed.') + setFeedbackTone('info') + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to reveal next clue.' + setError(message) + setFeedbackTone('error') + } finally { + setLoading(false) + } + } + + async function submitGuess(): Promise { + if (!round || !guess.trim()) { + return + } + + setLoading(true) + setError('') + + try { + const guessResult = await api.guess(round.roundId, guess) + const result = guessResult.data + if (result.isCorrect) { + setPoints((current) => current + result.awardedPoints) + } + + setRound((current) => { + if (!current) { + return current + } + + return { + ...current, + roundStatus: result.roundStatus, + answer: result.correctAnswer, + } + }) + refreshDebug(guessResult.rateLimit) + + if (result.roundStatus === 'won') { + setStatusMessage(`Correct! +${result.awardedPoints} points.`) + setFeedbackTone('success') + } else if (result.roundStatus === 'lost') { + setStatusMessage(`No more clues. Answer: ${result.correctAnswer ?? 'unknown'}`) + setFeedbackTone('warning') + } else if (result.roundStatus === 'expired') { + setStatusMessage('Round expired. Start a new round.') + setFeedbackTone('warning') + } else { + setStatusMessage('Not correct yet. Ask for the next clue or try again.') + setFeedbackTone('info') + } + + setGuess('') + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to submit guess.' + setError(message) + setFeedbackTone('error') + } finally { + setLoading(false) + } + } + + async function giveUpRound(): Promise { + if (!round) { + return + } + + setLoading(true) + setError('') + + try { + const giveUpResult = await api.giveUp(round.roundId) + const result = giveUpResult.data + setRound((current) => { + if (!current) { + return current + } + + return { + ...current, + roundStatus: result.roundStatus, + answer: result.correctAnswer, + } + }) + refreshDebug(giveUpResult.rateLimit) + + setStatusMessage(`You gave up. Answer: ${result.correctAnswer ?? 'unknown'}`) + setFeedbackTone('warning') + setGuess('') + } catch (err) { + const message = err instanceof Error ? err.message : 'Unable to give up this round.' + setError(message) + setFeedbackTone('error') + } finally { + setLoading(false) + } + } + + const scoreIfCorrect = round?.scoreIfCorrect ?? 0 + const clueStep = round ? `${round.clueIndex + 1}/${round.maxClues}` : '0/0' + const usedRequests = rateLimitDebug ? Math.max(0, rateLimitDebug.limit - rateLimitDebug.limitRemaining) : null + + return ( +
+
+ Lexicala Game +

Sense Sprint

+

+ Guess the hidden English word from lexical clues generated with Fluky Search. +

+
+ +
+
+
+

Total Points

+

{points}

+
+
+

Rounds Played

+

{roundsPlayed}

+
+
+

Time Left

+

{round ? `${timeLeft}s` : '-'}

+
+
+ +
+

Clue {clueStep}

+

{round?.clue ?? 'No active round yet.'}

+
+

Revealed clues

+ {revealedClues.length > 0 ? ( +
    + {revealedClues.map((revealedClue, index) => ( +
  1. + #{index + 1} + {revealedClue} +
  2. + ))} +
+ ) : ( +

No clues revealed yet.

+ )} +
+
+ +
+ setGuess(event.target.value)} + disabled={!canInteract} + onKeyDown={(event) => { + if (event.key === 'Enter') { + void submitGuess() + } + }} + /> + +
+ + + + +
+
+ +

+ Status: {statusMessage} +

+ {round?.roundStatus === 'won' ?

Word cracked. Keep the streak going!

: null} + {error ?

{error}

: null} +

Points available now: {scoreIfCorrect}

+ +
+ Debug Info +
+

+ Round ID: {round?.roundId ?? '-'} +

+

+ Round Status: {round?.roundStatus ?? '-'} +

+

+ Rate Limit: {rateLimitDebug ? `${rateLimitDebug.limitRemaining}/${rateLimitDebug.limit} remaining` : 'not available yet'} +

+

+ Rate Used: {usedRequests ?? '-'} +

+

+ Rate Reset (sec): {rateLimitDebug?.resetSeconds ?? '-'} +

+

+ API Calls: {debugCallCount} +

+

+ Debug Updated: {debugLastUpdated} +

+
+
+
+
+ ) +} + +export default App diff --git a/source/Demo/sense-sprint-web/src/assets/hero.png b/source/Demo/sense-sprint-web/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/source/Demo/sense-sprint-web/src/assets/hero.png differ diff --git a/source/Demo/sense-sprint-web/src/assets/react.svg b/source/Demo/sense-sprint-web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/source/Demo/sense-sprint-web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/source/Demo/sense-sprint-web/src/assets/vite.svg b/source/Demo/sense-sprint-web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/source/Demo/sense-sprint-web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/source/Demo/sense-sprint-web/src/index.css b/source/Demo/sense-sprint-web/src/index.css new file mode 100644 index 0000000..69cd615 --- /dev/null +++ b/source/Demo/sense-sprint-web/src/index.css @@ -0,0 +1,46 @@ +@import url('https://fonts.googleapis.com/css2?family=Chivo:wght@400;700;900&family=Space+Mono:wght@400;700&display=swap'); + +:root { + --bg-ink: #0f1720; + --bg-sky: #12293f; + --bg-sunset: #f7b267; + --panel: #fefae0; + --panel-strong: #fff5c4; + --text: #1f2937; + --accent: #ef476f; + --muted: #6b7280; + + --sans: 'Chivo', sans-serif; + --mono: 'Space Mono', monospace; + + font-family: var(--sans); + line-height: 1.4; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +body { + color: var(--text); + background: + radial-gradient(circle at 20% 20%, rgba(247, 178, 103, 0.35), transparent 38%), + radial-gradient(circle at 85% 8%, rgba(6, 214, 160, 0.24), transparent 30%), + linear-gradient(130deg, var(--bg-ink), var(--bg-sky)); +} + +p { + margin: 0; +} diff --git a/source/Demo/sense-sprint-web/src/main.tsx b/source/Demo/sense-sprint-web/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/source/Demo/sense-sprint-web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/source/Demo/sense-sprint-web/tsconfig.app.json b/source/Demo/sense-sprint-web/tsconfig.app.json new file mode 100644 index 0000000..7f42e5f --- /dev/null +++ b/source/Demo/sense-sprint-web/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/source/Demo/sense-sprint-web/tsconfig.json b/source/Demo/sense-sprint-web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/source/Demo/sense-sprint-web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/source/Demo/sense-sprint-web/tsconfig.node.json b/source/Demo/sense-sprint-web/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/source/Demo/sense-sprint-web/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/source/Demo/sense-sprint-web/vite.config.ts b/source/Demo/sense-sprint-web/vite.config.ts new file mode 100644 index 0000000..ab1dc12 --- /dev/null +++ b/source/Demo/sense-sprint-web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/game': { + target: 'http://localhost:5000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/source/Lexicala.NET.ConsoleApp/Lexicala.NET.ConsoleApp.csproj b/source/Lexicala.NET.ConsoleApp/Lexicala.NET.ConsoleApp.csproj deleted file mode 100644 index a9d4cce..0000000 --- a/source/Lexicala.NET.ConsoleApp/Lexicala.NET.ConsoleApp.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - Exe - net8.0 - 83e2368c-8e8a-4e8e-9f3d-c00d575b6393 - - - - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/source/Lexicala.NET.ConsoleApp/Program.cs b/source/Lexicala.NET.ConsoleApp/Program.cs deleted file mode 100644 index db6ca7d..0000000 --- a/source/Lexicala.NET.ConsoleApp/Program.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Lexicala.NET.Parsing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Threading.Tasks; -using Lexicala.NET.Request; -using Microsoft.Extensions.Logging; - -namespace Lexicala.NET.ConsoleApp -{ - public class Program - { - private static IServiceProvider _serviceProvider; - - public static async Task Main(string[] args) - { - IConfiguration configuration = new ConfigurationBuilder() - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .AddCommandLine(args) - // add your own user name and password to the user secrets store - .AddUserSecrets() - .Build(); - - try - { - RegisterServices(configuration); - - await ExecuteMainLoop(); - } - finally - { - DisposeServices(); - } - } - - private static async Task ExecuteMainLoop() - { - var parser = _serviceProvider.GetService(); - - string input = string.Empty; - while (input != ConsoleKey.Q.ToString()) - { - Console.WriteLine("Enter an entry ID directly, or enter search query: searchterm sourcelang targetlang (eg: estar es en). Q to exit"); - input = Console.ReadLine(); - if (input == null) - { - continue; - } - - - var tokens = input.Split(' '); - if (tokens.Length == 1) - { - var entry = await parser.GetEntryAsync(tokens[0], "ar", "en", "es", "nl", "zh" ); - } - if (tokens.Length != 3) - { - continue; - } - - var searchTerm = tokens[0]; - var srcLang = tokens[1]; - var tgtLang = tokens[2]; - if (srcLang.Length != 2 || tgtLang.Length != 2) - { - Console.WriteLine("Language must be 2 characters"); - continue; - } - - try - { - var request = new AdvancedSearchRequest() - { - SearchText = searchTerm, - Language = srcLang, - Analyzed = true - }; - - var result = await parser.SearchAsync(request); - var summary = result.Summary(tgtLang); - if (string.IsNullOrEmpty(summary)) - { - summary = "No results"; - } - Console.WriteLine(summary); - - //result = await parser.SearchAsync(searchTerm, srcLang, result.ETag); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - } - - private static void RegisterServices(IConfiguration configuration) - { - IServiceCollection services = new ServiceCollection(); - services.RegisterLexicala(configuration); - services.AddLogging(cfg => cfg.AddConsole()); - - _serviceProvider = services.BuildServiceProvider(true); - } - - private static void DisposeServices() - { - if (_serviceProvider == null) - { - return; - } - if (_serviceProvider is IDisposable disposable) - { - disposable.Dispose(); - } - } - } -} diff --git a/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj b/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj index 1118045..ede2e89 100644 --- a/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj +++ b/source/Lexicala.NET.Tests/Lexicala.NET.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 false @@ -17,8 +17,7 @@ - - + @@ -39,8 +38,7 @@ - - + @@ -55,12 +53,12 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/source/Lexicala.NET.Tests/LexicalaClientAdvancedSearchTests.cs b/source/Lexicala.NET.Tests/LexicalaClientAdvancedSearchTests.cs new file mode 100644 index 0000000..980f714 --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientAdvancedSearchTests.cs @@ -0,0 +1,236 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Request; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; +using Shouldly; + +namespace Lexicala.NET.Client.Tests +{ + [TestClass] + public class LexicalaClientAdvancedSearchTests : LexicalaClientTestBase + { + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_InvalidLanguageCode_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.AdvancedSearchAsync(new AdvancedSearchRequest())); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_EmptySearchText_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.AdvancedSearchAsync(new AdvancedSearchRequest { Language = "xx" })); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_InvalidSource_ThrowsException() + { + await Should.ThrowAsync(async () => + await Client.AdvancedSearchAsync(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Source = "en" })); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_NullSource_ThrowsException() + { + await Should.ThrowAsync(async () => + await Client.AdvancedSearchAsync(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Source = null })); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_AllDefaults() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text" }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Source() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Source = Sources.Password }, + "http://www.tempuri.org/search?language=xx&text=text&source=password"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Analyzed() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Analyzed = true }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&analyzed=true"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Monosemous() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Monosemous = true }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&monosemous=true"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Polysemous() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Polysemous = true }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&polysemous=true"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Morph() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Morph = true }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&morph=true"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Pos() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Pos = "noun" }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&pos=noun"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Number() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Number = "plural" }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&number=plural"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Gender() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Gender = "masculine" }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&gender=masculine"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Subcategorization() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Subcategorization = "feminine" }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&subcategorization=feminine"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Page_OtherThanDefault() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Page = 2 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&page=2"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Page_Default_NotInQuerystring() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Page = 1 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Page_Invalid_NotInQuerystring() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Page = 0 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_PageLength_OtherThanDefault() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", PageLength = 1 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&page-length=1"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_PageLength_Default_NotInQuerystring() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", PageLength = 10 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_PageLength_Invalid_Low_NotInQuerystring() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", PageLength = 0 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_PageLength_Invalid_High_NotInQuerystring() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", PageLength = 31 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Sample() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Sample = 1 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&sample=1"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Sample_NotGreaterThanZero_NotInQueryString() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Sample = 0 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Synonyms_InQueryString() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Synonyms = true }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&synonyms=true"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Antonyms_InQueryString() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Antonyms = true }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&antonyms=true"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_MultipleParameters_InQueryString() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest + { + Language = "xx", + SearchText = "text", + Analyzed = true, + Monosemous = true, + Synonyms = true, + Antonyms = true, + Pos = "noun", + Page = 2, + PageLength = 5 + }, + "http://www.tempuri.org/search?language=xx&text=text&source=global&analyzed=true&monosemous=true&synonyms=true&antonyms=true&pos=noun&page=2&page-length=5"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Page_Excessive_NotInQueryString() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Page = 1001 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearch_Sample_Excessive_NotInQueryString() + { + await AssertAdvancedSearchQuery(new AdvancedSearchRequest { Language = "xx", SearchText = "text", Sample = 1001 }, + "http://www.tempuri.org/search?language=xx&text=text&source=global"); + } + + private async Task AssertAdvancedSearchQuery(AdvancedSearchRequest searchRequest, string expectedUri) + { + string response = await LoadResponseFromFile("Search_empty.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + await Client.AdvancedSearchAsync(searchRequest); + + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == expectedUri), + ItExpr.IsAny()); + } + } +} diff --git a/source/Lexicala.NET.Tests/LexicalaClientBasicSearchTests.cs b/source/Lexicala.NET.Tests/LexicalaClientBasicSearchTests.cs new file mode 100644 index 0000000..82d1995 --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientBasicSearchTests.cs @@ -0,0 +1,81 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Response; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; +using Shouldly; + +namespace Lexicala.NET.Client.Tests +{ + [TestClass] + public class LexicalaClientBasicSearchTests : LexicalaClientTestBase + { + [TestMethod] + public async Task LexicalaClient_BasicSearch_InvalidLanguageCode_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.BasicSearchAsync("searchText", "ess")); + } + + [TestMethod] + public async Task LexicalaClient_BasicSearch_EmptySearchText_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.BasicSearchAsync("", "es")); + } + + [TestMethod] + public async Task LexicalaClient_BasicSearch_Es_Hacer() + { + string response = await LoadResponseFromFile("Search_Es_Hacer.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.BasicSearchAsync("searchText", "es"); + result.Results.Length.ShouldBe(3); + } + + [TestMethod] + public async Task LexicalaClient_Search_ETag() + { + string response = await LoadResponseFromFile("Search_empty.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + await Client.BasicSearchAsync("text", "xx", "W/\"abc-OfxtVSoa\""); + + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.Headers.Contains("If-None-Match")), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_Search_Metadata() + { + string response = await LoadResponseFromFile("Search_empty.json"); + + var responseMessage = SetupOkResponseMessage(response); + responseMessage.Headers.ETag = new EntityTagHeaderValue("\"abc-OfxtVSoa\""); + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsRemaining, "100"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsLimit, "1000"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitReset, "12345"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var result = await Client.BasicSearchAsync("text", "xx"); + + result.Metadata.ETag.ShouldBe("\"abc-OfxtVSoa\""); + result.Metadata.RateLimits.LimitRemaining.ShouldBe(100); + result.Metadata.RateLimits.Limit.ShouldBe(1000); + result.Metadata.RateLimits.Reset.ShouldBe(12345); + } + } +} diff --git a/source/Lexicala.NET.Tests/LexicalaClientEntryTests.cs b/source/Lexicala.NET.Tests/LexicalaClientEntryTests.cs new file mode 100644 index 0000000..9739565 --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientEntryTests.cs @@ -0,0 +1,214 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Request; +using Lexicala.NET.Response; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; +using Shouldly; + +namespace Lexicala.NET.Client.Tests +{ + [TestClass] + public class LexicalaClientEntryTests : LexicalaClientTestBase + { + [TestMethod] + public async Task LexicalaClient_GetEntry_En_Place() + { + string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.GetEntryAsync("EN_DE00009032"); + result.Id.ShouldBe("EN_DE00009032"); + result.Senses.Length.ShouldBe(12); + } + + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntry_EN_DE00009032() + { + await AssertEntryDeserializes("Entry_EN_DE00009032.json", "EN_DE00009032"); + } + + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntry_ES_DE00008087() + { + await AssertEntryDeserializes("Entry_ES_DE00008087.json", "ES_DE00008087"); + } + + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntry_ES_DE00008089() + { + await AssertEntryDeserializes("Entry_ES_DE00008088.json", "ES_DE00008088"); + } + + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntry_NL_DE00006941() + { + await AssertEntryDeserializes("Entry_NL_DE00006941.json", "NL_DE00006941"); + } + + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntry_PW00012363() + { + await AssertEntryDeserializes("Entry_PW00012363.json", "PW00012363"); + } + + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntry_RDE00032314_1() + { + await AssertEntryDeserializes("Entry_RDE00032314_1.json", "RDE00032314_1"); + } + + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntry_ES_DE00010530() + { + await AssertEntryDeserializes("ES_DE00010530.json", "ES_DE00010530"); + } + + [TestMethod] + public async Task LexicalaClient_GetEntry_ETag() + { + string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + await Client.GetEntryAsync("EN_DE00009032", "W/\"abc-OfxtVSoa\""); + + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.Headers.Contains("If-None-Match")), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_GetEntry_Metadata() + { + string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); + + var responseMessage = SetupOkResponseMessage(response); + responseMessage.Headers.ETag = new EntityTagHeaderValue("\"abc-OfxtVSoa\""); + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsRemaining, "75"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsLimit, "100"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitReset, "12345"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRapidFreePlanHardLimitLimit, "5000"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRapidFreePlanHardLimitRemaining, "1000"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRapidFreePlanHardLimitReset, "12345"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var result = await Client.GetEntryAsync("EN_DE00009032"); + + result.Metadata.ETag.ShouldBe("\"abc-OfxtVSoa\""); + result.Metadata.RateLimits.LimitRemaining.ShouldBe(75); + result.Metadata.RateLimits.Limit.ShouldBe(100); + result.Metadata.RateLimits.Reset.ShouldBe(12345); + } + + [TestMethod] + public async Task LexicalaClient_CanDeserializeEntry_ES_DE00019850() + { + await AssertEntryDeserializes("ES_DE00019850.json", "ES_DE00019850"); + } + + [TestMethod] + public async Task LexicalaClient_SearchEntries_Basic_IncludesSearchEntriesEndpoint() + { + string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage($"[{response}]")); + + var result = await Client.SearchEntriesAsync("text", "xx"); + + result.ShouldNotBeNull(); + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == "http://www.tempuri.org/search-entries?language=xx&text=text"), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_AdvancedSearchEntries_IncludesSearchEntriesEndpoint() + { + string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage($"[{response}]")); + + var searchRequest = new AdvancedSearchRequest + { + Language = "xx", + SearchText = "text", + Synonyms = true + }; + + var result = await Client.AdvancedSearchEntriesAsync(searchRequest); + + result.ShouldNotBeNull(); + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == "http://www.tempuri.org/search-entries?language=xx&text=text&source=global&synonyms=true"), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_GetEntryAsync_EncodesEntryIdInPath() + { + const string response = "{\"id\":\"id\",\"headword\":[],\"senses\":[],\"related_entries\":[]}"; + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + await Client.GetEntryAsync("EN_DE/unsafe"); + + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == "http://www.tempuri.org/entries/EN_DE%2Funsafe"), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_GetSenseAsync_EncodesSenseIdInPath() + { + const string response = "{\"id\":\"sense-id\"}"; + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + await Client.GetSenseAsync("EN_SE/unsafe"); + + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == "http://www.tempuri.org/senses/EN_SE%2Funsafe"), + ItExpr.IsAny()); + } + + [TestMethod] + public void Headword_PartOfSpeeches_ReturnsEmpty_WhenPosIsAbsent() + { + var headword = new Lexicala.NET.Response.Entries.Headword(); + + headword.PartOfSpeeches.ShouldBeEmpty(); + } + + private async Task AssertEntryDeserializes(string resourceFile, string entryId) + { + string response = await LoadResponseFromFile(resourceFile); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.GetEntryAsync(entryId); + result.ShouldNotBeNull(); + } + } +} diff --git a/source/Lexicala.NET.Tests/LexicalaClientFlukySearchTests.cs b/source/Lexicala.NET.Tests/LexicalaClientFlukySearchTests.cs new file mode 100644 index 0000000..d70466e --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientFlukySearchTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Request; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; +using Shouldly; + +namespace Lexicala.NET.Client.Tests +{ + [TestClass] + public class LexicalaClientFlukySearchTests : LexicalaClientTestBase + { + [TestMethod] + public async Task LexicalaClient_FlukySearch_NullSource_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.FlukySearchAsync(source: null)); + } + + [TestMethod] + public async Task LexicalaClient_FlukySearch_EmptySource_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.FlukySearchAsync(source: string.Empty)); + } + + [TestMethod] + public async Task LexicalaClient_FlukySearch_InvalidSource_ThrowsException() + { + await Should.ThrowAsync(async () => await Client.FlukySearchAsync(source: "en")); + } + + [TestMethod] + public async Task LexicalaClient_FlukySearch_GlobalSource_BuildsCorrectQuery() + { + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage("{\"results\":[],\"n_results\":0}")); + + await Client.FlukySearchAsync(source: Sources.Global); + + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString().Contains("source=global")), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_FlukySearch_MultiglossSource_BuildsCorrectQuery() + { + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage("{\"results\":[],\"n_results\":0}")); + + await Client.FlukySearchAsync(source: "multigloss"); + + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString().Contains("source=multigloss")), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_FlukySearch_SingleResultPayload_MapsToResultsArray() + { + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage("{\"id\":\"EN_TEST\",\"language\":\"en\",\"headword\":{\"text\":\"test\"},\"senses\":[{\"id\":\"EN_SE_TEST\",\"definition\":\"a test definition\"}] }")); + + 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_TEST"); + } + } +} diff --git a/source/Lexicala.NET.Tests/LexicalaClientHealthAndLanguagesTests.cs b/source/Lexicala.NET.Tests/LexicalaClientHealthAndLanguagesTests.cs new file mode 100644 index 0000000..d55a1ab --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientHealthAndLanguagesTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Lexicala.NET.Response; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Moq.Protected; +using Shouldly; + +namespace Lexicala.NET.Client.Tests +{ + [TestClass] + public class LexicalaClientHealthAndLanguagesTests : LexicalaClientTestBase + { + [TestMethod] + public async Task LexicalaClient_TestAsync() + { + string response = await LoadResponseFromFile("Test_Api_is_up.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.TestAsync(); + result.Message.ShouldBe("API is up"); + } + + [TestMethod] + public async Task LexicalaClient_TestAsync_ApiError_ThrowsLexicalaApiException() + { + const string response = "{\"message\":\"Bad request\"}"; + + var responseMessage = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(response) + }; + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsRemaining, "1"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsLimit, "10"); + responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitReset, "100"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var exception = await Should.ThrowAsync(async () => await Client.TestAsync()); + exception.StatusCode.ShouldBe(HttpStatusCode.BadRequest); + exception.Message.ShouldBe("Bad request"); + exception.Metadata.RateLimits.LimitRemaining.ShouldBe(1); + exception.Metadata.RateLimits.Limit.ShouldBe(10); + exception.Metadata.RateLimits.Reset.ShouldBe(100); + } + + [TestMethod] + public async Task LexicalaClient_LanguagesAsync() + { + string response = await LoadResponseFromFile("languages.json"); + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.LanguagesAsync(); + result.LanguageNames.ShouldNotBeEmpty(); + result.Resources.Global.SourceLanguages.ShouldNotBeEmpty(); + result.Resources.Password.SourceLanguages.ShouldNotBeEmpty(); + result.Resources.Random.SourceLanguages.ShouldNotBeEmpty(); + } + } +} diff --git a/source/Lexicala.NET.Tests/LexicalaClientRdfTests.cs b/source/Lexicala.NET.Tests/LexicalaClientRdfTests.cs new file mode 100644 index 0000000..11758b6 --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientRdfTests.cs @@ -0,0 +1,48 @@ +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 LexicalaClientRdfTests : LexicalaClientTestBase + { + [TestMethod] + public async Task LexicalaClient_SearchRdf_IncludesSearchRdfEndpoint() + { + const string response = "{ \"@context\": {}, \"@graph\": [] }"; + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.SearchRdfAsync("text", "xx"); + + result.ShouldBe(response); + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == "http://www.tempuri.org/search-rdf?language=xx&text=text"), + ItExpr.IsAny()); + } + + [TestMethod] + public async Task LexicalaClient_GetRdf_IncludesRdfEndpoint() + { + const string response = "{ \"@context\": {}, \"@graph\": [] }"; + + HandlerMock.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ReturnsAsync(SetupOkResponseMessage(response)); + + var result = await Client.GetRdfAsync("EN_DE00009032"); + + result.ShouldBe(response); + HandlerMock.Protected().Verify("SendAsync", Times.Once(), + ItExpr.Is(req => req.RequestUri.ToString() == "http://www.tempuri.org/rdf/EN_DE00009032"), + ItExpr.IsAny()); + } + } +} diff --git a/source/Lexicala.NET.Tests/LexicalaClientTestBase.cs b/source/Lexicala.NET.Tests/LexicalaClientTestBase.cs new file mode 100644 index 0000000..17f2c07 --- /dev/null +++ b/source/Lexicala.NET.Tests/LexicalaClientTestBase.cs @@ -0,0 +1,55 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Threading.Tasks; +using Moq; +using Moq.AutoMock; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Lexicala.NET.Client.Tests +{ + public abstract class LexicalaClientTestBase + { + protected Mock HandlerMock; + protected LexicalaClient Client; + + [TestInitialize] + public void Initialize() + { + HandlerMock = new Mock(); + var httpClient = new HttpClient(HandlerMock.Object) + { + BaseAddress = new Uri("http://www.tempuri.org") + }; + + var mocker = new AutoMocker(MockBehavior.Loose); + mocker.Use(httpClient); + + Client = mocker.CreateInstance(); + } + + protected static HttpResponseMessage SetupOkResponseMessage(string content) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content) + }; + } + + protected static async Task LoadResponseFromFile(string fileName) + { + var asm = Assembly.GetExecutingAssembly(); + using var resourceStream = asm.GetManifestResourceStream($"Lexicala.NET.Tests.Resources.{fileName}"); + + if (resourceStream != null) + { + using var reader = new StreamReader(resourceStream); + return await reader.ReadToEndAsync(); + } + + return string.Empty; + } + } +} diff --git a/source/Lexicala.NET.Tests/LexicalaClientTests.cs b/source/Lexicala.NET.Tests/LexicalaClientTests.cs deleted file mode 100644 index 06d2be3..0000000 --- a/source/Lexicala.NET.Tests/LexicalaClientTests.cs +++ /dev/null @@ -1,932 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; -using Moq.AutoMock; -using Moq.Protected; -using Shouldly; -using System; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using Lexicala.NET.Request; -using Lexicala.NET.Response; - -namespace Lexicala.NET.Client.Tests -{ - [TestClass] - public class LexicalaClientTests - { - // NOTE - to mock the HttpClient you need to mock the HttpMessageHandler that is used by the client. - // this requires to mock the internal protected 'SendAsync' method, which can by done with Moq using the not-so-common Protect namespace - - private Mock _handlerMock; - private LexicalaClient _client; - - [TestInitialize] - public void Initialize() - { - _handlerMock = new Mock(); - var httpClient = new HttpClient(_handlerMock.Object) - { - BaseAddress = new Uri("http://www.tempuri.org") - }; - - var mocker = new AutoMocker(MockBehavior.Loose); - mocker.Use(httpClient); - - _client = mocker.CreateInstance(); - } - - [TestMethod] - public async Task LexicalaClient_TestAsync() - { - string response = await LoadResponseFromFile("Test_Api_is_up.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.TestAsync(); - - // ASSERT - result.Message.ShouldBe("API is up"); - } - - [TestMethod] - public async Task LexicalaClient_MeAsync() - { - string response = await LoadResponseFromFile("me.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.MeAsync(); - - // ASSERT - result.Email.ShouldBe("foo@bar.com"); - result.Permissions.Activation.Activated.ShouldBeTrue(); - } - - [TestMethod] - public async Task LexicalaClient_LanguagesAsync() - { - string response = await LoadResponseFromFile("languages.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.LanguagesAsync(); - - // ASSERT - result.LanguageNames.ShouldNotBeEmpty(); - result.Resources.Global.SourceLanguages.ShouldNotBeEmpty(); - result.Resources.Password.SourceLanguages.ShouldNotBeEmpty(); - result.Resources.Random.SourceLanguages.ShouldNotBeEmpty(); - - } - - [TestMethod] - [ExpectedException(typeof(ArgumentException))] // ASSERT - public async Task LexicalaClient_BasicSearch_InvalidLanguageCode_ThrowsException() - { - // ACT - await _client.BasicSearchAsync("searchText", "ess"); - } - - [TestMethod] - [ExpectedException(typeof(ArgumentException))] // ASSERT - public async Task LexicalaClient_BasicSearch_EmptySearchText_ThrowsException() - { - // ACT - await _client.BasicSearchAsync("", "es"); - } - - [TestMethod] - public async Task LexicalaClient_BasicSearch_Es_Hacer() - { - string response = await LoadResponseFromFile("Search_Es_Hacer.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.BasicSearchAsync("searchText", "es"); - - // ASSERT - result.Results.Length.ShouldBe(3); - } - - [TestMethod] - [ExpectedException(typeof(ArgumentException))] // ASSERT - public async Task LexicalaClient_AdvancedSearch_InvalidLanguageCode_ThrowsException() - { - // ACT - await _client.AdvancedSearchAsync(new AdvancedSearchRequest()); - } - - [TestMethod] - [ExpectedException(typeof(ArgumentException))] // ASSERT - public async Task LexicalaClient_AdvancedSearch_EmptySearchText_ThrowsException() - { - // ACT - await _client.AdvancedSearchAsync(new AdvancedSearchRequest{Language = "xx"}); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_AllDefaults() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text" - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Source() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Source = Sources.Password - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=password" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Analyzed() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Analyzed = true - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&analyzed=true" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Monosemous() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Monosemous = true - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&monosemous=true" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Polysemous() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Polysemous = true - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&polysemous=true" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Morph() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Morph = true - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&morph=true" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Pos() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Pos = "noun" - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&pos=noun" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Number() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Number = "plural" - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&number=plural" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Gender() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Gender = "masculine" - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&gender=masculine" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Subcategorization() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Subcategorization = "feminine" - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&subcategorization=feminine" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Page_OtherThanDefault() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Page = 2 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&page=2" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Page_Default_NotInQuerystring() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Page = 1 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Page_Invalid_NotInQuerystring() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Page = 0 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_PageLength_OtherThanDefault() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - PageLength = 1 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&page-length=1" - ), ItExpr.IsAny()); - } - - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_PageLength_Default_NotInQuerystring() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - PageLength = 10 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_PageLength_Invalid_Low_NotInQuerystring() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - PageLength = 0 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_PageLength_Invalid_High_NotInQuerystring() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - PageLength = 31 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Sample() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Sample = 1 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global&sample=1" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_AdvancedSearch_Sample_NotGreaterThanZero_NotInQueryString() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - var searchRequest = new AdvancedSearchRequest - { - Language = "xx", - SearchText = "text", - Sample = 0 - }; - - // ACT - await _client.AdvancedSearchAsync(searchRequest); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.RequestUri.ToString() == "http://www.tempuri.org/search?language=xx&text=text&source=global" - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_Search_ETag() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - await _client.BasicSearchAsync("text", "xx", "W/\"abc-OfxtVSoa\""); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.Headers.Contains("If-None-Match") - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_Search_Metadata() - { - string response = await LoadResponseFromFile("Search_empty.json"); - - var responseMessage = SetupOkResponseMessage(response); - responseMessage.Headers.ETag = new EntityTagHeaderValue("\"abc-OfxtVSoa\""); - responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsRemaining, "100"); - responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsLimit, "1000"); - responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitReset, "12345"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(responseMessage); - - // ACT - var result = await _client.BasicSearchAsync("text", "xx"); - - // ASSERT - result.Metadata.ETag.ShouldBe("\"abc-OfxtVSoa\""); - result.Metadata.RateLimits.LimitRemaining.ShouldBe(100); - result.Metadata.RateLimits.Limit.ShouldBe(1000); - result.Metadata.RateLimits.Reset.ShouldBe(12345); - } - - [TestMethod] - public async Task LexicalaClient_GetEntry_En_Place() - { - string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("EN_DE00009032"); - - // ASSERT - result.Id.ShouldBe("EN_DE00009032"); - result.Senses.Length.ShouldBe(12); - } - - [TestMethod] - public async Task LexicalaClient_CanDeserializeEntry_EN_DE00009032() - { - string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("EN_DE00009032"); - - // ASSERT - result.ShouldNotBeNull(); - } - - [TestMethod] - public async Task LexicalaClient_CanDeserializeEntry_ES_DE00008087() - { - string response = await LoadResponseFromFile("Entry_ES_DE00008087.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("ES_DE00008087"); - - // ASSERT - result.ShouldNotBeNull(); - } - - [TestMethod] - public async Task LexicalaClient_CanDeserializeEntry_ES_DE00008089() - { - string response = await LoadResponseFromFile("Entry_ES_DE00008088.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("ES_DE00008088"); - - // ASSERT - result.ShouldNotBeNull(); - } - - [TestMethod] - public async Task LexicalaClient_CanDeserializeEntry_NL_DE00006941() - { - string response = await LoadResponseFromFile("Entry_NL_DE00006941.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("NL_DE00006941"); - - // ASSERT - result.ShouldNotBeNull(); - } - - [TestMethod] - public async Task LexicalaClient_CanDeserializeEntry_PW00012363() - { - string response = await LoadResponseFromFile("Entry_PW00012363.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("PW00012363"); - - // ASSERT - result.ShouldNotBeNull(); - } - - [TestMethod] - public async Task LexicalaClient_CanDeserializeEntry_RDE00032314_1() - { - string response = await LoadResponseFromFile("Entry_RDE00032314_1.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("RDE00032314_1"); - - // ASSERT - result.ShouldNotBeNull(); - } - - [TestMethod] - public async Task LexicalaClient_CanDeserializeEntry_ES_DE00010530() - { - string response = await LoadResponseFromFile("ES_DE00010530.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("ES_DE00010530"); - - // ASSERT - result.ShouldNotBeNull(); - } - - [TestMethod] - public async Task LexicalaClient_GetEntry_ETag() - { - string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - await _client.GetEntryAsync("EN_DE00009032", "W/\"abc-OfxtVSoa\""); - - // ASSERT - _handlerMock.Protected().Verify("SendAsync", Times.Once(), - ItExpr.Is(req => - req.Headers.Contains("If-None-Match") - ), ItExpr.IsAny()); - } - - [TestMethod] - public async Task LexicalaClient_GetEntry_Metadata() - { - string response = await LoadResponseFromFile("Entry_EN_DE00009032.json"); - - var responseMessage = SetupOkResponseMessage(response); - responseMessage.Headers.ETag = new EntityTagHeaderValue("\"abc-OfxtVSoa\""); - responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsRemaining, "75"); - responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitRequestsLimit, "100"); - responseMessage.Headers.Add(ResponseHeaders.HeaderRateLimitReset, "12345"); - responseMessage.Headers.Add(ResponseHeaders.HeaderRapidFreePlanHardLimitLimit, "5000"); - responseMessage.Headers.Add(ResponseHeaders.HeaderRapidFreePlanHardLimitRemaining, "1000"); - responseMessage.Headers.Add(ResponseHeaders.HeaderRapidFreePlanHardLimitReset, "12345"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(responseMessage); - - // ACT - var result = await _client.GetEntryAsync("EN_DE00009032"); - - // ASSERT - result.Metadata.ETag.ShouldBe("\"abc-OfxtVSoa\""); - result.Metadata.RateLimits.LimitRemaining.ShouldBe(75); - result.Metadata.RateLimits.Limit.ShouldBe(100); - result.Metadata.RateLimits.Reset.ShouldBe(12345); - } - - [TestMethod] - public async Task LexicalaClient_CanDeserializeEntry_ES_DE00019850() - { - string response = await LoadResponseFromFile("ES_DE00019850.json"); - - _handlerMock.Protected() - .Setup>("SendAsync", ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(SetupOkResponseMessage(response)); - - // ACT - var result = await _client.GetEntryAsync("ES_DE00019850"); - - // ASSERT - result.ShouldNotBeNull(); - } - - private static HttpResponseMessage SetupOkResponseMessage(string content) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(content) - }; - } - - private static Task LoadResponseFromFile(string fileName) - { - var asm = Assembly.GetExecutingAssembly(); - using var resourceStream = asm.GetManifestResourceStream($"Lexicala.NET.Tests.Resources.{fileName}"); - - if (resourceStream != null) - { - using var reader = new StreamReader(resourceStream); - return reader.ReadToEndAsync(); - } - - return Task.FromResult(string.Empty); - } - } -} diff --git a/source/Lexicala.NET.Tests/Parsing/LexicalaSearchParserTests.cs b/source/Lexicala.NET.Tests/Parsing/LexicalaSearchParserTests.cs index 43dc1ea..78241e8 100644 --- a/source/Lexicala.NET.Tests/Parsing/LexicalaSearchParserTests.cs +++ b/source/Lexicala.NET.Tests/Parsing/LexicalaSearchParserTests.cs @@ -1,15 +1,18 @@ -using Lexicala.NET.Client; +using Lexicala.NET.Client; using Microsoft.Extensions.Caching.Memory; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Moq.AutoMock; -using Newtonsoft.Json; +using System.Text.Json; using Shouldly; +using System; using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Lexicala.NET.Parsing; +using Lexicala.NET.Request; using Lexicala.NET.Response; using Lexicala.NET.Response.Entries; using Lexicala.NET.Response.Entries.JsonConverters; @@ -45,7 +48,7 @@ public void Initialize() }; _mocker.GetMock() - .Setup(m => m.LanguagesAsync()) + .Setup(m => m.LanguagesAsync(It.IsAny())) .ReturnsAsync(languagesResponse); _mocker.GetMock() @@ -60,21 +63,21 @@ public async Task LexicalaDetailsLoader_SearchAsync_1() string entry1 = await LoadResponseFromFile("ES_DE00008087.json"); string entry2 = await LoadResponseFromFile("ES_DE00008088.json"); - var apiResult = JsonConvert.DeserializeObject(searchResult, SearchResponseJsonConverter.Settings); + var apiResult = JsonSerializer.Deserialize(searchResult, SearchResponseJsonConverter.Settings); apiResult.Metadata = new ResponseMetadata(); - var entryResult1 = JsonConvert.DeserializeObject(entry1, EntryResponseJsonConverter.Settings); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); entryResult1.Metadata = new ResponseMetadata(); - var entryResult2 = JsonConvert.DeserializeObject(entry2, EntryResponseJsonConverter.Settings); + var entryResult2 = JsonSerializer.Deserialize(entry2, EntryResponseJsonConverter.Settings); entryResult2.Metadata = new ResponseMetadata(); _mocker.GetMock() - .Setup(m => m.BasicSearchAsync("test", "es", null)) + .Setup(m => m.BasicSearchAsync("test", "es", null, It.IsAny())) .ReturnsAsync(apiResult); _mocker.GetMock() - .Setup(m => m.GetEntryAsync("ES_DE00008088", null)) + .Setup(m => m.GetEntryAsync("ES_DE00008088", null, It.IsAny())) .ReturnsAsync(entryResult2); _mocker.GetMock() - .Setup(m => m.GetEntryAsync("ES_DE00008087", null)) + .Setup(m => m.GetEntryAsync("ES_DE00008087", null, It.IsAny())) .ReturnsAsync(entryResult1); @@ -93,20 +96,20 @@ public async Task LexicalSearchParser_Parse_ES_DE00019850() string searchResult = await LoadResponseFromFile("Search_es_sin embargo.json"); string entry1 = await LoadResponseFromFile("ES_DE00019850.json"); - var apiResult = JsonConvert.DeserializeObject(searchResult, SearchResponseJsonConverter.Settings); + var apiResult = JsonSerializer.Deserialize(searchResult, SearchResponseJsonConverter.Settings); apiResult.Metadata = new ResponseMetadata(); - var entryResult1 = JsonConvert.DeserializeObject(entry1, EntryResponseJsonConverter.Settings); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); entryResult1.Metadata = new ResponseMetadata(); _mocker.GetMock() - .Setup(m => m.BasicSearchAsync("test", "es", null)) + .Setup(m => m.BasicSearchAsync("test", "es", null, It.IsAny())) .ReturnsAsync(apiResult); _mocker.GetMock() - .Setup(m => m.GetEntryAsync(It.IsAny(), null)) + .Setup(m => m.GetEntryAsync(It.IsAny(), null, It.IsAny())) .ReturnsAsync(new Entry()); _mocker.GetMock() - .Setup(m => m.GetEntryAsync("ES_DE00019850", null)) - .ReturnsAsync(entryResult1); + .Setup(m => m.GetEntryAsync("ES_DE00019850", null, It.IsAny())) + .ReturnsAsync(entryResult1); var result = await _lexicalaSearchParser.SearchAsync("test", "es"); @@ -122,6 +125,421 @@ public async Task LexicalSearchParser_Parse_ES_DE00019850() } } + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_MultipleTargetLanguages() + { + string searchResult = await LoadResponseFromFile("Search_es_blando_analyzed.json"); + string entry1 = await LoadResponseFromFile("ES_DE00008087.json"); + + var apiResult = JsonSerializer.Deserialize(searchResult, SearchResponseJsonConverter.Settings); + apiResult.Metadata = new ResponseMetadata(); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); + entryResult1.Metadata = new ResponseMetadata(); + + _mocker.GetMock() + .Setup(m => m.BasicSearchAsync("test", "es", null, It.IsAny())) + .ReturnsAsync(apiResult); + _mocker.GetMock() + .Setup(m => m.GetEntryAsync(It.IsAny(), null, It.IsAny())) + .ReturnsAsync(entryResult1); + + // ACT + var result = await _lexicalaSearchParser.SearchAsync("test", "es", "en", "nl"); + + // ASSERT + result.ShouldNotBeNull(); + result.SearchText.ShouldBe("test"); + result.Results.ShouldNotBeEmpty(); + } + + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_InvalidSourceLanguage_ThrowsException() + { + // ACT & ASSERT + await Should.ThrowAsync(async () => + await _lexicalaSearchParser.SearchAsync("test", "invalid")); + } + + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_EmptySearchText_ThrowsException() + { + // ACT & ASSERT + await Should.ThrowAsync(async () => + await _lexicalaSearchParser.SearchAsync("", "es")); + } + + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_NullSearchText_ThrowsException() + { + // ACT & ASSERT + await Should.ThrowAsync(async () => + await _lexicalaSearchParser.SearchAsync((string)null, "es")); + } + + [TestMethod] + public async Task LexicalaSearchParser_AdvancedSearchAsync_InvalidLanguage_ThrowsException() + { + var searchRequest = new AdvancedSearchRequest + { + Language = "invalid", + SearchText = "test" + }; + + // ACT & ASSERT + await Should.ThrowAsync(async () => + await _lexicalaSearchParser.SearchAsync(searchRequest)); + } + + [TestMethod] + public async Task LexicalaSearchParser_AdvancedSearchAsync_EmptySearchText_ThrowsException() + { + var searchRequest = new AdvancedSearchRequest + { + Language = "es", + SearchText = "" + }; + + // ACT & ASSERT + await Should.ThrowAsync(async () => + await _lexicalaSearchParser.SearchAsync(searchRequest)); + } + + [TestMethod] + public async Task LexicalaSearchParser_AdvancedSearchAsync_NullRequest_ThrowsException() + { + // ACT & ASSERT + await Should.ThrowAsync(async () => + await _lexicalaSearchParser.SearchAsync((AdvancedSearchRequest)null)); + } + + [TestMethod] + public async Task LexicalaSearchParser_GetEntryAsync_ValidEntryId() + { + string entry1 = await LoadResponseFromFile("ES_DE00008087.json"); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); + entryResult1.Metadata = new ResponseMetadata(); + + _mocker.GetMock() + .Setup(m => m.GetEntryAsync("ES_DE00008087", null, It.IsAny())) + .ReturnsAsync(entryResult1); + + // ACT + var result = await _lexicalaSearchParser.GetEntryAsync("ES_DE00008087"); + + // ASSERT + result.ShouldNotBeNull(); + result.Id.ShouldBe("ES_DE00008087"); + } + + [TestMethod] + public async Task LexicalaSearchParser_GetEntryAsync_WithTargetLanguages() + { + string entry1 = await LoadResponseFromFile("ES_DE00008087.json"); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); + entryResult1.Metadata = new ResponseMetadata(); + + _mocker.GetMock() + .Setup(m => m.GetEntryAsync("ES_DE00008087", null, It.IsAny())) + .ReturnsAsync(entryResult1); + + // ACT + var result = await _lexicalaSearchParser.GetEntryAsync("ES_DE00008087", "en", "nl"); + + // ASSERT + result.ShouldNotBeNull(); + result.Id.ShouldBe("ES_DE00008087"); + } + + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_WithTargetLanguages_FiltersTranslations() + { + string searchResult = await LoadResponseFromFile("Search_es_blando_analyzed.json"); + string entry1 = await LoadResponseFromFile("ES_DE00008087.json"); + + var apiResult = JsonSerializer.Deserialize(searchResult, SearchResponseJsonConverter.Settings); + apiResult.Metadata = new ResponseMetadata(); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); + entryResult1.Metadata = new ResponseMetadata(); + + _mocker.GetMock() + .Setup(m => m.BasicSearchAsync("test", "es", null, It.IsAny())) + .ReturnsAsync(apiResult); + _mocker.GetMock() + .Setup(m => m.GetEntryAsync(It.IsAny(), null, It.IsAny())) + .ReturnsAsync(entryResult1); + + // ACT + var result = await _lexicalaSearchParser.SearchAsync("test", "es", "en", "nl"); + + // ASSERT + result.ShouldNotBeNull(); + result.Results.ShouldNotBeEmpty(); + + // Verify that senses only contain translations for the specified target languages + foreach (var resultEntry in result.Results) + { + foreach (var sense in resultEntry.Senses) + { + // All translations should only be in "en" or "nl" + foreach (var translation in sense.Translations) + { + translation.Language.ShouldBeOneOf("en", "nl"); + } + + // Verify examples also respect the target language filter + foreach (var example in sense.Examples) + { + foreach (var translation in example.Translations) + { + translation.Language.ShouldBeOneOf("en", "nl"); + } + } + + // Verify compositional phrases also respect the target language filter + foreach (var compPhrase in sense.CompositionalPhrases) + { + foreach (var translation in compPhrase.Translations) + { + translation.Language.ShouldBeOneOf("en", "nl"); + } + + foreach (var example in compPhrase.Examples) + { + foreach (var translation in example.Translations) + { + translation.Language.ShouldBeOneOf("en", "nl"); + } + } + } + } + } + } + + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_NoTargetLanguages_ReturnsAllTranslations() + { + string searchResult = await LoadResponseFromFile("Search_es_blando_analyzed.json"); + string entry1 = await LoadResponseFromFile("ES_DE00008087.json"); + + var apiResult = JsonSerializer.Deserialize(searchResult, SearchResponseJsonConverter.Settings); + apiResult.Metadata = new ResponseMetadata(); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); + entryResult1.Metadata = new ResponseMetadata(); + + _mocker.GetMock() + .Setup(m => m.BasicSearchAsync("test", "es", null, It.IsAny())) + .ReturnsAsync(apiResult); + _mocker.GetMock() + .Setup(m => m.GetEntryAsync(It.IsAny(), null, It.IsAny())) + .ReturnsAsync(entryResult1); + + // ACT + var result = await _lexicalaSearchParser.SearchAsync("test", "es"); + + // ASSERT + result.ShouldNotBeNull(); + result.Results.ShouldNotBeEmpty(); + + // Verify that senses contain translations in all available languages + var allLanguages = result.Results + .SelectMany(r => r.Senses) + .SelectMany(s => s.Translations) + .Select(t => t.Language) + .Distinct() + .ToList(); + + // Should have multiple languages (br, en, ja, nl, no, sv, etc.) + allLanguages.Count.ShouldBeGreaterThan(1); + } + + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_AdvancedSearch_WithTargetLanguages() + { + string searchResult = await LoadResponseFromFile("Search_es_blando_analyzed.json"); + string entry1 = await LoadResponseFromFile("ES_DE00008087.json"); + + var apiResult = JsonSerializer.Deserialize(searchResult, SearchResponseJsonConverter.Settings); + apiResult.Metadata = new ResponseMetadata(); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); + entryResult1.Metadata = new ResponseMetadata(); + + _mocker.GetMock() + .Setup(m => m.AdvancedSearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(apiResult); + _mocker.GetMock() + .Setup(m => m.GetEntryAsync(It.IsAny(), null, It.IsAny())) + .ReturnsAsync(entryResult1); + + var searchRequest = new AdvancedSearchRequest + { + Language = "es", + SearchText = "test" + }; + + // ACT + var result = await _lexicalaSearchParser.SearchAsync(searchRequest, "en", "fr"); + + // ASSERT + result.ShouldNotBeNull(); + result.Results.ShouldNotBeEmpty(); + + // Verify that only "en" and "fr" translations are returned + foreach (var resultEntry in result.Results) + { + foreach (var sense in resultEntry.Senses) + { + foreach (var translation in sense.Translations) + { + translation.Language.ShouldBeOneOf("en", "fr"); + } + } + } + } + + [TestMethod] + public async Task LexicalaSearchParser_GetEntryAsync_WithTargetLanguages_FiltersTranslations() + { + string entry1 = await LoadResponseFromFile("ES_DE00008087.json"); + var entryResult1 = JsonSerializer.Deserialize(entry1, EntryResponseJsonConverter.Settings); + entryResult1.Metadata = new ResponseMetadata(); + + _mocker.GetMock() + .Setup(m => m.GetEntryAsync("ES_DE00008087", null, It.IsAny())) + .ReturnsAsync(entryResult1); + + // ACT + var result = await _lexicalaSearchParser.GetEntryAsync("ES_DE00008087", "en", "nl"); + + // ASSERT + result.ShouldNotBeNull(); + result.Id.ShouldBe("ES_DE00008087"); + + // Verify translations are filtered to only "en" and "nl" + foreach (var sense in result.Senses) + { + foreach (var translation in sense.Translations) + { + translation.Language.ShouldBeOneOf("en", "nl"); + } + } + } + + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_BoundsConcurrentEntryRequests() + { + const int totalEntries = 16; + var apiResult = new SearchResponse + { + NResults = totalEntries, + Results = Enumerable.Range(1, totalEntries).Select(i => new Result { Id = $"ID_{i}" }).ToArray(), + Metadata = new ResponseMetadata { RateLimits = new RateLimits { LimitRemaining = 100 } } + }; + + var currentInFlight = 0; + var maxInFlight = 0; + + _mocker.GetMock() + .Setup(m => m.BasicSearchAsync("test", "es", null, It.IsAny())) + .ReturnsAsync(apiResult); + + _mocker.GetMock() + .Setup(m => m.GetEntryAsync(It.IsAny(), null, It.IsAny())) + .Returns(async (string id, string etag, CancellationToken cancellationToken) => + { + var inFlight = Interlocked.Increment(ref currentInFlight); + UpdateMax(ref maxInFlight, inFlight); + + await Task.Delay(25, cancellationToken); + + Interlocked.Decrement(ref currentInFlight); + return CreateMinimalEntry(id, 100); + }); + + var result = await _lexicalaSearchParser.SearchAsync("test", "es"); + + result.Results.Count.ShouldBe(totalEntries); + maxInFlight.ShouldBeLessThanOrEqualTo(4); + } + + [TestMethod] + public async Task LexicalaSearchParser_SearchAsync_UsesSingleConcurrencyWhenRateLimitIsLow() + { + const int totalEntries = 8; + var apiResult = new SearchResponse + { + NResults = totalEntries, + Results = Enumerable.Range(1, totalEntries).Select(i => new Result { Id = $"LOW_{i}" }).ToArray(), + Metadata = new ResponseMetadata { RateLimits = new RateLimits { LimitRemaining = 3 } } + }; + + var currentInFlight = 0; + var maxInFlight = 0; + + _mocker.GetMock() + .Setup(m => m.BasicSearchAsync("test", "es", null, It.IsAny())) + .ReturnsAsync(apiResult); + + _mocker.GetMock() + .Setup(m => m.GetEntryAsync(It.IsAny(), null, It.IsAny())) + .Returns(async (string id, string etag, CancellationToken cancellationToken) => + { + var inFlight = Interlocked.Increment(ref currentInFlight); + UpdateMax(ref maxInFlight, inFlight); + + await Task.Delay(20, cancellationToken); + + Interlocked.Decrement(ref currentInFlight); + return CreateMinimalEntry(id, 3); + }); + + var result = await _lexicalaSearchParser.SearchAsync("test", "es"); + + result.Results.Count.ShouldBe(totalEntries); + maxInFlight.ShouldBe(1); + } + + private static Entry CreateMinimalEntry(string id, int limitRemaining) + { + return new Entry + { + Id = id, + HeadwordObject = new Lexicala.NET.Response.Entries.Headword + { + Text = id, + Pos = "noun", + PronunciationObject = new Pronunciation { Value = "p" }, + AdditionalInflections = [], + Inflections = [] + }, + Senses = [], + RelatedEntries = [], + Metadata = new ResponseMetadata + { + RateLimits = new RateLimits + { + LimitRemaining = limitRemaining + } + } + }; + } + + private static void UpdateMax(ref int maxValue, int currentValue) + { + while (true) + { + var observed = maxValue; + if (currentValue <= observed) + { + return; + } + + if (Interlocked.CompareExchange(ref maxValue, currentValue, observed) == observed) + { + return; + } + } + } + private static Task LoadResponseFromFile(string fileName) { var asm = Assembly.GetExecutingAssembly(); @@ -138,3 +556,4 @@ private static Task LoadResponseFromFile(string fileName) } } } + diff --git a/source/Lexicala.NET.Tests/Resources/languages.json b/source/Lexicala.NET.Tests/Resources/languages.json index a475531..93e5977 100644 --- a/source/Lexicala.NET.Tests/Resources/languages.json +++ b/source/Lexicala.NET.Tests/Resources/languages.json @@ -7,9 +7,8 @@ "br": "Brazilian Portuguese", "ca": "Catalan", "cs": "Czech", - "da": "Canadian French", + "da": "Danish", "de": "German", - "dk": "Danish", "el": "Greek", "en": "English", "es": "Spanish", @@ -17,6 +16,7 @@ "fa": "Farsi", "fi": "Finnish", "fr": "French", + "fr-ca": "Canadian French", "fy": "Frisian", "he": "Hebrew", "hi": "Hindi", @@ -57,8 +57,8 @@ "ar", "br", "cs", + "da", "de", - "dk", "el", "en", "es", @@ -85,11 +85,11 @@ "br", "da", "de", - "dk", "el", "en", "es", "fr", + "fr-ca", "he", "it", "ja", @@ -116,8 +116,8 @@ "br", "ca", "cs", + "da", "de", - "dk", "el", "es", "et", diff --git a/source/Lexicala.NET.Tests/Resources/me.json b/source/Lexicala.NET.Tests/Resources/me.json deleted file mode 100644 index 9c6ec64..0000000 --- a/source/Lexicala.NET.Tests/Resources/me.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "username": "foo", - "email": "foo@bar.com", - "permissions": { - "activation": { - "activated": true, - "agreed_terms_of_use": true - }, - "pro": false, - "enterprise": false, - "requests_per_day": 300, - "creation_date": "2019-06-05T10:59:47.339Z" - }, - "usage": { - "today": { - "count": 20, - "ends_at": "2020-08-16T00:00:00.000Z" - }, - "lifetime": 1000, - "history": { - "6_2020": 100, - "7_2020": 200, - "8_2020": 300 - } - } -} \ No newline at end of file diff --git a/source/Lexicala.NET.sln b/source/Lexicala.NET.sln deleted file mode 100644 index e4cc0b8..0000000 --- a/source/Lexicala.NET.sln +++ /dev/null @@ -1,37 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34701.34 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lexicala.NET", "Lexicala.NET\Lexicala.NET.csproj", "{4EAB2C7F-3CBA-43CB-97B6-DE2B7B89DFD0}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lexicala.NET.Tests", "Lexicala.NET.Tests\Lexicala.NET.Tests.csproj", "{C5BFFF1B-CA39-49D1-9EBD-1F0A6CEAF577}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lexicala.NET.ConsoleApp", "Lexicala.NET.ConsoleApp\Lexicala.NET.ConsoleApp.csproj", "{8CF2797E-7845-4E69-A252-108C2FAB13A3}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4EAB2C7F-3CBA-43CB-97B6-DE2B7B89DFD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4EAB2C7F-3CBA-43CB-97B6-DE2B7B89DFD0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4EAB2C7F-3CBA-43CB-97B6-DE2B7B89DFD0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4EAB2C7F-3CBA-43CB-97B6-DE2B7B89DFD0}.Release|Any CPU.Build.0 = Release|Any CPU - {C5BFFF1B-CA39-49D1-9EBD-1F0A6CEAF577}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C5BFFF1B-CA39-49D1-9EBD-1F0A6CEAF577}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C5BFFF1B-CA39-49D1-9EBD-1F0A6CEAF577}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C5BFFF1B-CA39-49D1-9EBD-1F0A6CEAF577}.Release|Any CPU.Build.0 = Release|Any CPU - {8CF2797E-7845-4E69-A252-108C2FAB13A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8CF2797E-7845-4E69-A252-108C2FAB13A3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8CF2797E-7845-4E69-A252-108C2FAB13A3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8CF2797E-7845-4E69-A252-108C2FAB13A3}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {FD4D002A-2425-4328-BE36-D7F6CB4EFEA7} - EndGlobalSection -EndGlobal diff --git a/source/Lexicala.NET.slnx b/source/Lexicala.NET.slnx new file mode 100644 index 0000000..ec0d767 --- /dev/null +++ b/source/Lexicala.NET.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/source/Lexicala.NET/Constants.cs b/source/Lexicala.NET/Constants.cs new file mode 100644 index 0000000..8d1ba15 --- /dev/null +++ b/source/Lexicala.NET/Constants.cs @@ -0,0 +1,71 @@ +namespace Lexicala.NET +{ + /// + /// Contains API endpoint constants for the Lexicala service. + /// + internal static class Constants + { + /// + /// Test endpoint path. + /// + internal const string Test = "/test"; + + /// + /// Basic search endpoint path. + /// + internal const string Search = "/search"; + + /// + /// Search entries endpoint path. + /// + internal const string SearchEntries = "/search-entries"; + + /// + /// Entries endpoint path. + /// + internal const string Entries = "/entries"; + + /// + /// Search RDF endpoint path. + /// + internal const string SearchRdf = "/search-rdf"; + + /// + /// RDF endpoint path. + /// + internal const string Rdf = "/rdf"; + + /// + /// Languages endpoint path. + /// + internal const string Languages = "/languages"; + + /// + /// Senses endpoint path. + /// + internal const string Senses = "/senses"; + + /// + /// Search definitions endpoint path. + /// + internal const string SearchDefinitions = "/search-definitions"; + + /// + /// Fluky search endpoint path (random word discovery). + /// + internal const string FlukySearch = "/fluky-search"; + + /// + /// Maximum threshold for pagination and sampling parameters to prevent excessive API requests. + /// Used to validate page numbers and sample sizes submitted by clients. + /// Value of 1000 is chosen to balance flexibility with resource protection. + /// + /// + /// This limit prevents abuse from: + /// - Page numbers that would request excessive amounts of data + /// - Sample parameters that would return too many results + /// Clients requesting values above this threshold will have their parameters clamped or ignored. + /// + internal const int MaxRequestThreshold = 1000; + } +} \ No newline at end of file diff --git a/source/Lexicala.NET/DependencyRegistration.cs b/source/Lexicala.NET/DependencyRegistration.cs index da57b5e..27d8840 100644 --- a/source/Lexicala.NET/DependencyRegistration.cs +++ b/source/Lexicala.NET/DependencyRegistration.cs @@ -1,32 +1,113 @@ -using Lexicala.NET.Parsing; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Lexicala.NET.Parsing; +using Lexicala.NET.Response; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Polly; +using Polly.Extensions.Http; namespace Lexicala.NET { + /// + /// Provides dependency injection registration helpers for Lexicala services. + /// 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) { + if (config == null) + { + throw new ArgumentNullException(nameof(config)); + } + + if (string.IsNullOrWhiteSpace(config.ApiKey)) + { + throw new ArgumentException("ApiKey must be provided and cannot be empty", nameof(config.ApiKey)); + } + services.AddHttpClient(client => { client.BaseAddress = LexicalaConfig.BaseAddress; client.DefaultRequestHeaders.Add(LexicalaConfig.RapidApiKeyHeader, config.ApiKey); client.DefaultRequestHeaders.Add(LexicalaConfig.RapidApiHostHeader, LexicalaConfig.RapidApiHostValue); }) - .AddTransientHttpErrorPolicy(builder => builder.RetryAsync(3)); + .AddPolicyHandler(CreateRetryPolicy()); services.AddMemoryCache(); services.AddSingleton(); return services; } + + private static IAsyncPolicy CreateRetryPolicy() + { + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(response => response.StatusCode == HttpStatusCode.TooManyRequests) + .RetryAsync(3, async (outcome, retryAttempt, _) => + { + var retryDelay = GetRetryDelay(outcome.Result, retryAttempt); + if (retryDelay > TimeSpan.Zero) + { + await Task.Delay(retryDelay); + } + }); + } + + private static TimeSpan GetRetryDelay(HttpResponseMessage response, int retryAttempt) + { + if (response != null) + { + if (response.Headers.RetryAfter?.Delta is TimeSpan delta && delta > TimeSpan.Zero) + { + return delta; + } + + if (response.Headers.RetryAfter?.Date is DateTimeOffset date) + { + var retryAfterDateDelay = date - DateTimeOffset.UtcNow; + if (retryAfterDateDelay > TimeSpan.Zero) + { + return retryAfterDateDelay; + } + } + + if (response.Headers.TryGetValues(ResponseHeaders.HeaderRateLimitReset, out var values)) + { + var value = values.FirstOrDefault(); + if (int.TryParse(value, out var secondsUntilReset) && secondsUntilReset > 0) + { + return TimeSpan.FromSeconds(secondsUntilReset); + } + } + } + + // Exponential backoff fallback when no server guidance is available. + return TimeSpan.FromSeconds(Math.Min(Math.Pow(2, retryAttempt), 8)); + } } } \ No newline at end of file diff --git a/source/Lexicala.NET/ILexicalaClient.cs b/source/Lexicala.NET/ILexicalaClient.cs index ac141f0..304b3c4 100644 --- a/source/Lexicala.NET/ILexicalaClient.cs +++ b/source/Lexicala.NET/ILexicalaClient.cs @@ -1,8 +1,10 @@ -using System.Threading.Tasks; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Lexicala.NET.Request; using Lexicala.NET.Response.Entries; using Lexicala.NET.Response.Languages; -using Lexicala.NET.Response.Me; using Lexicala.NET.Response.Search; using Lexicala.NET.Response.Test; @@ -16,38 +18,89 @@ public interface ILexicalaClient /// /// Test that the API is up. /// - Task TestAsync(); + /// Thrown when the API returns an error or is not responding. + Task TestAsync(CancellationToken cancellationToken = default); + /// - /// View your user account settings. + /// Gets information about languages available through the API. /// /// - /// This includes the personal details such as your name and the email you have provided upon registration, your request cap, and the number of requests used in the last 24 hours. + ///By default, results are from KD's Global series. Data from the Password Series and from Random House Webster's College Dictionary are also available. The Global series includes 25 monolingual cores, which are added translation equivalents, producing multilingual versions. The Password series consists of an English core, translated to 46 languages. The Random House Webster's College Dictionary is an extensive monolingual English dictionary. /// - Task MeAsync(); + /// Thrown when the API returns an error. + Task LanguagesAsync(CancellationToken cancellationToken = default); /// - /// Gets information about languages available through the API. + /// Search for entries in the 'Global' source. /// /// - ///By default, results are from KD's Global series. Data from the Password Series and from Random House Webster's College Dictionary are also available. The Global series includes 24 monolingual cores, which are added translation equivalents, producing multilingual versions. The Password series consists of an English core, translated to 46 languages. The Random House Webster's College Dictionary is an extensive monolingual English dictionary. + /// The search result consists of a JSON object containing partial lexical information on entries that match the search criteria. To obtain further, more in-depth, information for each entry, see . /// - Task LanguagesAsync(); + /// Specify a headword + /// Specify which source language to look in (must be a 2-character language code). + /// Optional. + /// Token used to cancel the request. + /// Thrown when searchText is null or empty, or when sourceLanguage is not a valid 2-character language code. + /// Thrown when the API returns an error. + Task BasicSearchAsync(string searchText, string sourceLanguage, string etag = null, CancellationToken cancellationToken = default); /// - /// Search for entries in the 'Global' source. + /// Search for entries in the 'Global' source and return full entries. /// /// - /// The search result consists of a JSON object containing partial lexical information on entries that match the search criteria. To obtain further, more in-depth, information for each entry, see . + /// The search result consists of full entry objects that match the search criteria. /// /// Specify a headword - /// Specify which source language to look in. + /// Specify which source language to look in (must be a 2-character language code). /// Optional. - Task BasicSearchAsync(string searchText, string sourceLanguage, string etag = null); + /// Token used to cancel the request. + /// Thrown when searchText is null or empty, or when sourceLanguage is not a valid 2-character language code. + /// Thrown when the API returns an error. + Task> SearchEntriesAsync(string searchText, string sourceLanguage, string etag = null, CancellationToken cancellationToken = default); + /// + /// Search for entries in RDF/JSON-LD format. + /// + /// Specify a headword + /// Specify which source language to look in (must be a 2-character language code). + /// Optional. + /// Token used to cancel the request. + /// Thrown when searchText is null or empty, or when sourceLanguage is not a valid 2-character language code. + /// Thrown when the API returns an error. + Task SearchRdfAsync(string searchText, string sourceLanguage, string etag = null, CancellationToken cancellationToken = default); + /// + /// Search for entries in RDF/JSON-LD format using the parameters that are provided in the . + /// + /// Thrown when searchRequest is null. + /// Thrown when searchRequest properties are invalid (e.g., null/empty Language or SearchText, invalid language codes, or invalid parameter values). + /// Thrown when the API returns an error. + Task AdvancedSearchRdfAsync(AdvancedSearchRequest searchRequest, CancellationToken cancellationToken = default); + /// + /// Retrieve an entry in RDF/JSON-LD format by entry ID. + /// + /// The entry ID + /// Optional. + /// Token used to cancel the request. + /// Thrown when entryId is null or empty. + /// Thrown when the API returns an error or the entry is not found. + Task GetRdfAsync(string entryId, string etag = null, CancellationToken cancellationToken = default); /// /// Search for entries using the parameters that are provided in the . /// /// /// The search result consists of a JSON object containing partial lexical information on entries that match the search criteria. To obtain further, more in-depth information for each entry, see . /// - Task AdvancedSearchAsync(AdvancedSearchRequest searchRequest); + /// Thrown when searchRequest is null. + /// Thrown when searchRequest properties are invalid (e.g., null/empty Language or SearchText, invalid language codes, or invalid parameter values). + /// Thrown when the API returns an error. + Task AdvancedSearchAsync(AdvancedSearchRequest searchRequest, CancellationToken cancellationToken = default); + /// + /// Search for entries using the parameters that are provided in the and return full entries. + /// + /// + /// The search result consists of full entry objects that match the search criteria. + /// + /// Thrown when searchRequest is null. + /// Thrown when searchRequest properties are invalid (e.g., null/empty Language or SearchText, invalid language codes, or invalid parameter values). + /// Thrown when the API returns an error. + Task> AdvancedSearchEntriesAsync(AdvancedSearchRequest searchRequest, CancellationToken cancellationToken = default); /// /// Retrieve a dictionary entry by entry ID. /// @@ -56,12 +109,44 @@ public interface ILexicalaClient /// /// The entry ID /// Optional. - Task GetEntryAsync(string entryId, string etag = null); + /// Token used to cancel the request. + /// Thrown when entryId is null or empty. + /// Thrown when the API returns an error or the entry is not found. + Task GetEntryAsync(string entryId, string etag = null, CancellationToken cancellationToken = default); /// /// Retrieve a sense by it's ID. /// /// The sense ID /// Optional. - Task GetSenseAsync(string senseId, string etag = null); + /// Token used to cancel the request. + /// Thrown when senseId is null or empty. + /// Thrown when the API returns an error or the sense is not found. + Task GetSenseAsync(string senseId, string etag = null, CancellationToken cancellationToken = default); + /// + /// Search for entries by performing a free-text search in definitions. + /// + /// + /// Performs a full-text search in definitions across 20 supported languages (ar, br, cs, da, de, el, en, es, fr, he, hi, it, ja, ko, nl, no, pl, pt, ru, sv, th, tr). + /// + /// The text to search for in definitions + /// Optional. Filters results to match entries in the specified language (must be a 2-character language code if provided). The search text itself can be in any language. + /// Optional. + /// Token used to cancel the request. + /// Thrown when searchText is null or empty, or when language is provided but is not a valid 2-character language code. + /// Thrown when the API returns an error. + Task SearchDefinitionsAsync(string searchText, string language = null, string etag = null, CancellationToken cancellationToken = default); + /// + /// Get a randomly selected entry for word discovery. + /// + /// + /// Returns a random entry from the available resources, useful for discovering words across supported languages. + /// + /// Specify which resource to look in (global, password, multigloss, random). Default is global. + /// Optional. Specify which source language to look in (must be a 2-character language code if provided); if not specified, the language is chosen randomly. + /// Optional. + /// Token used to cancel the request. + /// 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); } } \ No newline at end of file diff --git a/source/Lexicala.NET/InternalsVisibleTo.cs b/source/Lexicala.NET/InternalsVisibleTo.cs new file mode 100644 index 0000000..251c425 --- /dev/null +++ b/source/Lexicala.NET/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Lexicala.NET.Tests")] diff --git a/source/Lexicala.NET/JsonSerializerDefaults.cs b/source/Lexicala.NET/JsonSerializerDefaults.cs new file mode 100644 index 0000000..cb8574e --- /dev/null +++ b/source/Lexicala.NET/JsonSerializerDefaults.cs @@ -0,0 +1,63 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Lexicala.NET.Response.Entries; +using Lexicala.NET.Response.Entries.JsonConverters; +using Lexicala.NET.Response.Search; + +namespace Lexicala.NET +{ + internal sealed class SnakeCaseNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return name; + } + + var builder = new StringBuilder(); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c)) + { + if (builder.Length > 0) + { + builder.Append('_'); + } + builder.Append(char.ToLowerInvariant(c)); + } + else + { + builder.Append(c); + } + } + + return builder.ToString(); + } + } + + internal static class JsonSerializerDefaults + { + public static readonly JsonSerializerOptions Options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = new SnakeCaseNamingPolicy(), + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false), + new TranslationObjectConverter(), + new Lexicala.NET.Response.Entries.JsonConverters.HeadwordObjectConverter(), + new PronunciationObjectConverter(), + new PosConverter(), + new AlternativeScriptsObjectConverter(), + new FlexibleIntConverter(), + new Lexicala.NET.Response.Search.HeadwordObjectConverter() + } + }; + } +} diff --git a/source/Lexicala.NET/Lexicala.NET.csproj b/source/Lexicala.NET/Lexicala.NET.csproj index c3a13ab..48c21b0 100644 --- a/source/Lexicala.NET/Lexicala.NET.csproj +++ b/source/Lexicala.NET/Lexicala.NET.csproj @@ -1,10 +1,10 @@  - net8.0;netstandard2.0 + net8.0;net10.0 true false - true + true Hanno Zwikstra Lexicala.NET A .NET client for the Lexicala api. @@ -13,24 +13,26 @@ https://github.com/HannoZ/Lexicala.NET Public Lexicala, Lexicala.NET, C#, translation, dictionary + README.md A .NET client for the Lexicala api. See readme file on project page for further details. - 2.0.0 + 3.0.0 latest - + + + + + + + + + - - - - - - - - - + + diff --git a/source/Lexicala.NET/LexicalaApiException.cs b/source/Lexicala.NET/LexicalaApiException.cs new file mode 100644 index 0000000..26d6dc1 --- /dev/null +++ b/source/Lexicala.NET/LexicalaApiException.cs @@ -0,0 +1,42 @@ +using System; +using System.Net; +using Lexicala.NET.Response; + +namespace Lexicala.NET +{ + /// + /// Represents an error response returned by the Lexicala API. + /// + public class LexicalaApiException : Exception + { + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The HTTP status code returned by the API. + /// The raw response content returned by the API. + /// Parsed response metadata, including rate limit headers. + public LexicalaApiException(string message, HttpStatusCode statusCode, string content, ResponseMetadata metadata) + : base(message) + { + StatusCode = statusCode; + Content = content; + Metadata = metadata; + } + + /// + /// Gets the HTTP status code returned by the API. + /// + public HttpStatusCode StatusCode { get; } + + /// + /// Gets the raw response content returned by the API. + /// + public string Content { get; } + + /// + /// Gets parsed response metadata, including rate limit headers. + /// + public ResponseMetadata Metadata { get; } + } +} diff --git a/source/Lexicala.NET/LexicalaClient.cs b/source/Lexicala.NET/LexicalaClient.cs index 3d651da..2fb7b0c 100644 --- a/source/Lexicala.NET/LexicalaClient.cs +++ b/source/Lexicala.NET/LexicalaClient.cs @@ -1,18 +1,18 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; -using System.Text; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Lexicala.NET.Request; using Lexicala.NET.Response; using Lexicala.NET.Response.Entries; -using Lexicala.NET.Response.Entries.JsonConverters; using Lexicala.NET.Response.Languages; -using Lexicala.NET.Response.Me; using Lexicala.NET.Response.Search; using Lexicala.NET.Response.Test; -using Newtonsoft.Json; +using Microsoft.Extensions.Logging; using Sense = Lexicala.NET.Response.Entries.Sense; namespace Lexicala.NET @@ -21,14 +21,7 @@ namespace Lexicala.NET public class LexicalaClient : ILexicalaClient { private readonly HttpClient _httpClient; - - private const string Test = "/test"; - private const string Me = "/users/me"; - private const string Search = "/search"; - private const string SearchEntries = "/search-entries"; - private const string Entries = "/entries"; - private const string Languages = "/languages"; - private const string Senses = "/senses"; + private readonly ILogger _logger; /// /// Creates a new instance of the class. @@ -36,173 +29,335 @@ public class LexicalaClient : ILexicalaClient /// /// This class should not be instantiated directly, but registered as implementation of the interface in the dependency injection framework. /// - public LexicalaClient(HttpClient httpClient) + public LexicalaClient(HttpClient httpClient, ILogger logger) { _httpClient = httpClient; + _logger = logger; } /// - public async Task TestAsync() + public async Task TestAsync(CancellationToken cancellationToken = default) { - var response = await _httpClient.GetStringAsync(Test); - return JsonConvert.DeserializeObject(response); + using var response = await ExecuteRequestAsync(HttpMethod.Get, Constants.Test, cancellationToken: cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize(content, JsonSerializerDefaults.Options); + } + + /// + public async Task LanguagesAsync(CancellationToken cancellationToken = default) + { + using var response = await ExecuteRequestAsync(HttpMethod.Get, Constants.Languages, cancellationToken: cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize(content, JsonSerializerDefaults.Options); } /// - public async Task MeAsync() + public Task BasicSearchAsync(string searchText, string sourceLanguage, string etag = null, CancellationToken cancellationToken = default) { - var response = await _httpClient.GetStringAsync(Me); - return JsonConvert.DeserializeObject(response); + ValidateLanguageCode(sourceLanguage, nameof(sourceLanguage)); + ArgumentException.ThrowIfNullOrEmpty(searchText, nameof(searchText)); + + _logger.LogDebug("Performing basic search for text '{SearchText}' in language '{SourceLanguage}'", searchText, sourceLanguage); + + var query = $"{Constants.Search}?language={Uri.EscapeDataString(sourceLanguage)}&text={Uri.EscapeDataString(searchText)}"; + return ExecuteSearch(query, etag, cancellationToken); } /// - public async Task LanguagesAsync() + public Task AdvancedSearchAsync(AdvancedSearchRequest searchRequest, CancellationToken cancellationToken = default) { - var response = await _httpClient.GetStringAsync(Languages); - return JsonConvert.DeserializeObject(response); + ValidateSearchRequest(searchRequest); + + _logger.LogDebug("Performing advanced search for text '{SearchText}' in language '{SourceLanguage}' with parameters: synonyms={Synonyms}, antonyms={Antonyms}", + searchRequest.SearchText, searchRequest.Language, searchRequest.Synonyms, searchRequest.Antonyms); + + var queryString = BuildAdvancedSearchQueryString(Constants.Search, searchRequest); + return ExecuteSearch(queryString, searchRequest.ETag, cancellationToken); } /// - public Task BasicSearchAsync(string searchText, string sourceLanguage, string etag = null) + public Task> SearchEntriesAsync(string searchText, string sourceLanguage, string etag = null, CancellationToken cancellationToken = default) { - if (sourceLanguage.Length != 2) - { - throw new ArgumentException($"Invalid language code provided ({sourceLanguage}), a valid language code is two characters", nameof(sourceLanguage)); - } + ValidateLanguageCode(sourceLanguage, nameof(sourceLanguage)); + ArgumentException.ThrowIfNullOrEmpty(searchText, nameof(searchText)); - if (string.IsNullOrEmpty(searchText)) - { - throw new ArgumentException("SearchText cannot be empty", nameof(searchText)); - } + var queryString = $"{Constants.SearchEntries}?language={Uri.EscapeDataString(sourceLanguage)}&text={Uri.EscapeDataString(searchText)}"; + return ExecuteSearchEntries(queryString, etag, cancellationToken); + } + + /// + public Task> AdvancedSearchEntriesAsync(AdvancedSearchRequest searchRequest, CancellationToken cancellationToken = default) + { + ValidateSearchRequest(searchRequest); - return ExecuteSearch($"{Search}?language={sourceLanguage}&text={searchText}", etag); + var queryString = BuildAdvancedSearchQueryString(Constants.SearchEntries, searchRequest); + return ExecuteSearchEntries(queryString, searchRequest.ETag, cancellationToken); } /// - public Task AdvancedSearchAsync(AdvancedSearchRequest searchRequest) + public Task SearchRdfAsync(string searchText, string sourceLanguage, string etag = null, CancellationToken cancellationToken = default) { - if (searchRequest.Language?.Length != 2) - { - throw new ArgumentException($"Invalid language code provided ({searchRequest.Language}), a valid language code is two characters"); - } + ValidateLanguageCode(sourceLanguage, nameof(sourceLanguage)); + ArgumentException.ThrowIfNullOrEmpty(searchText, nameof(searchText)); - if (string.IsNullOrEmpty(searchRequest.SearchText)) - { - throw new ArgumentException("SearchText cannot be empty"); - } + var query = $"{Constants.SearchRdf}?language={Uri.EscapeDataString(sourceLanguage)}&text={Uri.EscapeDataString(searchText)}"; + return ExecuteRdfQuery(query, etag, cancellationToken); + } - // build the querystring based on provided search request params - StringBuilder queryStringBuilder = new StringBuilder($"{Search}?language={searchRequest.Language}&text={searchRequest.SearchText}"); - queryStringBuilder.Append("&source=" + searchRequest.Source); - - if (searchRequest.Analyzed) - { - queryStringBuilder.Append("&analyzed=true"); - } - if (searchRequest.Monosemous) - { - queryStringBuilder.Append("&monosemous=true"); - } - if (searchRequest.Polysemous) - { - queryStringBuilder.Append("&polysemous=true"); - } - if (searchRequest.Morph) + /// + public Task AdvancedSearchRdfAsync(AdvancedSearchRequest searchRequest, CancellationToken cancellationToken = default) + { + ValidateSearchRequest(searchRequest); + + var queryString = BuildAdvancedSearchQueryString(Constants.SearchRdf, searchRequest); + return ExecuteRdfQuery(queryString, searchRequest.ETag, cancellationToken); + } + + /// + public Task GetRdfAsync(string entryId, string etag = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(entryId, nameof(entryId)); + return ExecuteRdfQuery($"{Constants.Rdf}/{Uri.EscapeDataString(entryId)}", etag, cancellationToken); + } + + /// + public async Task GetEntryAsync(string entryId, string etag = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(entryId, nameof(entryId)); + using var response = await ExecuteRequestAsync(HttpMethod.Get, $"{Constants.Entries}/{Uri.EscapeDataString(entryId)}", etag, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var responseObject = JsonSerializer.Deserialize(content, JsonSerializerDefaults.Options); + responseObject.Metadata = GetResponseMetadata(response.Headers); + return responseObject; + } + + /// + public async Task GetSenseAsync(string senseId, string etag = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(senseId, nameof(senseId)); + using var response = await ExecuteRequestAsync(HttpMethod.Get, $"{Constants.Senses}/{Uri.EscapeDataString(senseId)}", etag, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var responseObject = JsonSerializer.Deserialize(content, JsonSerializerDefaults.Options); + responseObject.Metadata = GetResponseMetadata(response.Headers); + return responseObject; + } + + /// + public Task SearchDefinitionsAsync(string searchText, string language = null, string etag = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(searchText, nameof(searchText)); + + _logger.LogDebug("Performing definitions search for text '{SearchText}' with language filter '{Language}'", searchText, language); + + var query = $"{Constants.SearchDefinitions}?text={Uri.EscapeDataString(searchText)}"; + if (!string.IsNullOrEmpty(language)) { - queryStringBuilder.Append("&morph=true"); + ValidateLanguageCode(language, nameof(language)); + query += $"&lang={Uri.EscapeDataString(language)}"; } - if (!string.IsNullOrEmpty(searchRequest.Pos)) + + return ExecuteSearch(query, etag, cancellationToken); + } + + /// + public Task FlukySearchAsync(string source = "global", string language = null, string etag = null, CancellationToken cancellationToken = default) + { + ValidateSource(source, nameof(source)); + + _logger.LogDebug("Performing fluky search in source '{Source}' with language '{Language}'", source, language ?? "random"); + + var query = $"{Constants.FlukySearch}?source={Uri.EscapeDataString(source)}"; + if (!string.IsNullOrEmpty(language)) { - queryStringBuilder.Append("&pos=" + searchRequest.Pos); + ValidateLanguageCode(language, nameof(language)); + query += $"&language={Uri.EscapeDataString(language)}"; } - if (!string.IsNullOrEmpty(searchRequest.Number)) + + return ExecuteSearch(query, etag, cancellationToken); + } + + private static string BuildAdvancedSearchQueryString(string endpoint, AdvancedSearchRequest searchRequest) + { + var queryParameters = new List> { - queryStringBuilder.Append("&number=" + searchRequest.Number); - } - if (!string.IsNullOrEmpty(searchRequest.Gender)) + new("language", searchRequest.Language), + new("text", searchRequest.SearchText), + new("source", searchRequest.Source) + }; + + // Add optional boolean search criteria flags only when explicitly enabled. + AddIfTrue(searchRequest.Analyzed, "analyzed"); + AddIfTrue(searchRequest.Monosemous, "monosemous"); + AddIfTrue(searchRequest.Polysemous, "polysemous"); + AddIfTrue(searchRequest.Morph, "morph"); + AddIfTrue(searchRequest.Synonyms, "synonyms"); + AddIfTrue(searchRequest.Antonyms, "antonyms"); + + // Add optional string filters only when provided. + AddIfNotEmpty(searchRequest.Pos, "pos"); + AddIfNotEmpty(searchRequest.Number, "number"); + AddIfNotEmpty(searchRequest.Gender, "gender"); + AddIfNotEmpty(searchRequest.Subcategorization, "subcategorization"); + + // Enforce existing pagination constraints. + if (searchRequest.Page is > 1 and <= Constants.MaxRequestThreshold) { - queryStringBuilder.Append("&gender=" + searchRequest.Gender); + queryParameters.Add(new KeyValuePair("page", searchRequest.Page.ToString())); } - if (!string.IsNullOrEmpty(searchRequest.Subcategorization)) + + if (searchRequest.PageLength != 10 && searchRequest.PageLength is > 0 and <= 30) { - queryStringBuilder.Append("&subcategorization=" + searchRequest.Subcategorization); + queryParameters.Add(new KeyValuePair("page-length", searchRequest.PageLength.ToString())); } - // pagination - only append if values are other than default values - if (searchRequest.Page > 1) + if (searchRequest.Sample is > 0 and <= Constants.MaxRequestThreshold) { - queryStringBuilder.Append("&page=" + searchRequest.Page); + queryParameters.Add(new KeyValuePair("sample", searchRequest.Sample.ToString())); } - if (searchRequest.PageLength != 10 && searchRequest.PageLength is > 0 and <= 30) + + return BuildQueryString(endpoint, queryParameters); + + void AddIfTrue(bool include, string key) { - queryStringBuilder.Append("&page-length=" + searchRequest.PageLength); + if (include) + { + queryParameters.Add(new KeyValuePair(key, "true")); + } } - if (searchRequest.Sample > 0) + + void AddIfNotEmpty(string value, string key) { - queryStringBuilder.Append("&sample=" + searchRequest.Sample); + if (!string.IsNullOrEmpty(value)) + { + queryParameters.Add(new KeyValuePair(key, value)); + } } - - return ExecuteSearch(queryStringBuilder.ToString(), searchRequest.ETag); } - private async Task ExecuteSearch(string querystring, string etag) + private static string BuildQueryString(string endpoint, IEnumerable> queryParameters) { - using var httpRequest = new HttpRequestMessage(HttpMethod.Get, querystring); - AddETagIfPresent(etag, httpRequest); - - using var response = await _httpClient.SendAsync(httpRequest); + var encodedPairs = queryParameters + .Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value)}"); - // until we have a better error-handling mechanism we just let the code throw built-in exception if request was not successful - response.EnsureSuccessStatusCode(); + return $"{endpoint}?{string.Join("&", encodedPairs)}"; + } - string result = await response.Content.ReadAsStringAsync(); + private static readonly HashSet ValidSources = new(StringComparer.OrdinalIgnoreCase) + { + Sources.Global, Sources.Password, Sources.Random, Sources.Multigloss + }; - var responseObject = JsonConvert.DeserializeObject(result, SearchResponseJsonConverter.Settings); - responseObject.Metadata = GetResponseMetadata(response.Headers); + private static void ValidateSource(string source, string parameterName) + { + ArgumentException.ThrowIfNullOrEmpty(source, parameterName); + if (!ValidSources.Contains(source)) + { + throw new ArgumentException($"Invalid source provided ({source}), valid values are: {string.Join(", ", ValidSources)}", parameterName); + } + } - return responseObject; + private static void ValidateSearchRequest(AdvancedSearchRequest searchRequest) + { + ArgumentNullException.ThrowIfNull(searchRequest); + ValidateLanguageCode(searchRequest.Language, nameof(searchRequest.Language)); + ArgumentException.ThrowIfNullOrEmpty(searchRequest.SearchText, nameof(searchRequest.SearchText)); + ValidateSource(searchRequest.Source, nameof(searchRequest.Source)); } + private static void ValidateLanguageCode(string languageCode, string parameterName) + { + ArgumentException.ThrowIfNullOrEmpty(languageCode, parameterName); + if (languageCode.Length != 2) + { + throw new ArgumentException($"Invalid language code provided ({languageCode}), a valid language code is two characters", parameterName); + } + } - /// - public async Task GetEntryAsync(string entryId, string etag = null) + private async Task ExecuteRequestAsync(HttpMethod method, string endpoint, string etag = null, CancellationToken cancellationToken = default) { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{Entries}/{entryId}"); + var request = new HttpRequestMessage(method, endpoint); AddETagIfPresent(etag, request); - using var response = await _httpClient.SendAsync(request); + _logger.LogDebug("Executing {Method} request to {Endpoint}", method, endpoint); - // until we have a better error-handling mechanism we just let the code throw built-in exception if request was not successful - response.EnsureSuccessStatusCode(); - - var result = await response.Content.ReadAsStringAsync(); + var response = await _httpClient.SendAsync(request, cancellationToken); - var responseObject = JsonConvert.DeserializeObject(result, EntryResponseJsonConverter.Settings); - responseObject.Metadata = GetResponseMetadata(response.Headers); + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Request to {Endpoint} failed with status code {StatusCode}", endpoint, response.StatusCode); + throw await CreateApiExceptionAsync(response, cancellationToken); + } - return responseObject; + _logger.LogDebug("Request to {Endpoint} succeeded with status code {StatusCode}", endpoint, response.StatusCode); + return response; } - /// - public async Task GetSenseAsync(string senseId, string etag = null) - { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{Senses}/{senseId}"); - AddETagIfPresent(etag, request); - using var response = await _httpClient.SendAsync(request); + private async Task ExecuteSearch(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 = DeserializeSearchResponse(content); + responseObject.Metadata = GetResponseMetadata(response.Headers); + return responseObject; + } - // until we have a better error-handling mechanism we just let the code throw built-in exception if request was not successful - response.EnsureSuccessStatusCode(); + private static SearchResponse DeserializeSearchResponse(string content) + { + var responseObject = JsonSerializer.Deserialize(content, JsonSerializerDefaults.Options); + if (responseObject?.Results is { Length: > 0 }) + { + return responseObject; + } - var result = await response.Content.ReadAsStringAsync(); + try + { + using var document = JsonDocument.Parse(content); + var root = document.RootElement; - var responseObject = JsonConvert.DeserializeObject(result, EntryResponseJsonConverter.Settings); - responseObject.Metadata = GetResponseMetadata(response.Headers); + if (root.ValueKind == JsonValueKind.Object && + !root.TryGetProperty("results", out _) && + root.TryGetProperty("id", out _)) + { + var singleResult = JsonSerializer.Deserialize(content, JsonSerializerDefaults.Options); + if (singleResult?.Id != null) + { + return new SearchResponse + { + NResults = 1, + PageNumber = 1, + ResultsPerPage = 1, + NPages = 1, + AvailableNPages = 1, + Results = [singleResult] + }; + } + } + } + catch (JsonException) + { + // Keep original deserialization result for malformed content. + } - return responseObject; + return responseObject ?? new SearchResponse(); + } + private async Task> ExecuteSearchEntries(string querystring, string etag, CancellationToken cancellationToken) + { + using var response = await ExecuteRequestAsync(HttpMethod.Get, querystring, etag, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var entries = JsonSerializer.Deserialize>(content, JsonSerializerDefaults.Options); + // Note: Metadata is per entry, but since it's a collection, perhaps set on each or return as is + // For simplicity, return the entries; metadata can be handled differently if needed + return entries; } + private async Task ExecuteRdfQuery(string querystring, string etag, CancellationToken cancellationToken) + { + using var response = await ExecuteRequestAsync(HttpMethod.Get, querystring, etag, cancellationToken); + return await response.Content.ReadAsStringAsync(cancellationToken); + } + private static void AddETagIfPresent(string etag, HttpRequestMessage request) { if (etag != null) @@ -211,7 +366,20 @@ private static void AddETagIfPresent(string etag, HttpRequestMessage request) } } - private static ResponseMetadata GetResponseMetadata(HttpResponseHeaders headers) + /// + /// Extracts response metadata from HTTP response headers, including ETag and rate limit information. + /// + /// The HTTP response headers to parse + /// A ResponseMetadata object containing ETag and rate limit information. Rate limit values are set to -1 if the corresponding headers are missing or unparseable. + /// + /// This method extracts: + /// - ETag header for caching purposes + /// - Rate limit headers (limit, remaining, reset) from the API response + /// + /// If any rate limit headers are missing or contain unparseable values, they are set to -1 and a warning is logged. + /// Callers should check for -1 values when rate limit information is required for decision-making. + /// + private ResponseMetadata GetResponseMetadata(HttpResponseHeaders headers) { return new ResponseMetadata { @@ -224,18 +392,98 @@ private static ResponseMetadata GetResponseMetadata(HttpResponseHeaders headers) } }; + // Parses a rate limit header value from the API response. + // Returns the parsed integer value, or -1 if the header is missing or cannot be parsed. int ParseRateLimitHeader(string header) { - if (headers.TryGetValues(header, out var headerValues) && headerValues.Count() == 1) + if (headers.TryGetValues(header, out var headerValues)) { - if (int.TryParse(headerValues.First(), out var value)) + var headerValuesList = headerValues.ToList(); + foreach (var headerValue in headerValuesList) { - return value; + if (int.TryParse(headerValue, out var value)) + { + return value; + } } + + // Header exists but value couldn't be parsed + _logger.LogWarning("Rate limit header '{HeaderName}' exists but contains unparseable values: {HeaderValue}", header, string.Join(", ", headerValuesList)); + } + else + { + // Header is missing from the response + _logger.LogWarning("Rate limit header '{HeaderName}' is missing from the API response. This may indicate an API issue or version mismatch.", header); } return -1; } } + + private async Task CreateApiExceptionAsync(HttpResponseMessage response, CancellationToken cancellationToken = default) + { + 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); + + return new LexicalaApiException(message, response.StatusCode, content, GetResponseMetadata(response.Headers)); + } + + private static string GetErrorMessageFromContent(string content) + { + if (string.IsNullOrEmpty(content)) + { + return null; + } + + try + { + // Parse JSON response and attempt to extract error message from multiple possible locations + // Different API endpoints and error conditions may use different error response formats + using var document = JsonDocument.Parse(content); + var root = document.RootElement; + + // Try primary error message fields in order of preference: + // "message" - standard error message field + // "error" - OAuth/standard error field + // "error_description" - OAuth error description field + if (TryGetString(root, "message", out var message) || + TryGetString(root, "error", out message) || + TryGetString(root, "error_description", out message)) + { + return message; + } + + // Check for nested error object with message (some error responses have nested structure) + // Example: { "error": { "message": "Error details" } } + if (root.TryGetProperty("error", out var errorProperty) && errorProperty.ValueKind == JsonValueKind.Object) + { + if (TryGetString(errorProperty, "message", out var nestedMessage)) + { + return nestedMessage; + } + } + } + catch (JsonException) + { + // Return a generic message for malformed JSON instead of silently ignoring + return "The API returned a response that could not be parsed. The response may contain invalid JSON."; + } + + return null; + + static bool TryGetString(JsonElement element, string propertyName, out string value) + { + if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String) + { + value = property.GetString(); + return true; + } + + value = null; + return false; + } + } } } diff --git a/source/Lexicala.NET/LexicalaConfig.cs b/source/Lexicala.NET/LexicalaConfig.cs index 2c2b56f..fa7eb92 100644 --- a/source/Lexicala.NET/LexicalaConfig.cs +++ b/source/Lexicala.NET/LexicalaConfig.cs @@ -12,8 +12,19 @@ public class LexicalaConfig /// public static readonly Uri BaseAddress = new Uri("https://lexicala1.p.rapidapi.com"); + /// + /// HTTP header name used for the RapidAPI key. + /// public const string RapidApiKeyHeader = "X-RapidAPI-Key"; + + /// + /// HTTP header name used for the RapidAPI host. + /// public const string RapidApiHostHeader = "X-RapidAPI-Host"; + + /// + /// RapidAPI host header value for the Lexicala API. + /// public const string RapidApiHostValue = "lexicala1.p.rapidapi.com"; /// diff --git a/source/Lexicala.NET/Parsing/CollectionExtensions.cs b/source/Lexicala.NET/Parsing/CollectionExtensions.cs deleted file mode 100644 index 19ec269..0000000 --- a/source/Lexicala.NET/Parsing/CollectionExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Lexicala.NET.Parsing -{ - internal static class CollectionExtensions - { - public static void AddRange(this ICollection source, IEnumerable collection) - { - foreach(var item in collection) - { - source.Add(item); - } - } - } -} diff --git a/source/Lexicala.NET/Parsing/ILexicalaSearchParser.cs b/source/Lexicala.NET/Parsing/ILexicalaSearchParser.cs index 173ddb6..29fd7d4 100644 --- a/source/Lexicala.NET/Parsing/ILexicalaSearchParser.cs +++ b/source/Lexicala.NET/Parsing/ILexicalaSearchParser.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using Lexicala.NET.Parsing.Dto; using Lexicala.NET.Request; @@ -12,17 +13,28 @@ public interface ILexicalaSearchParser /// /// Executes a search request and subsequent calls to load the entry information of the search result. /// + /// The text to search for + /// The source language code (e.g., "en", "es", "de") - must be a valid 2-character language code + /// Specifies the languages for which translations are returned (if any). When no specific languages are specified, all available translations are returned. + /// Thrown when searchTerm is null or empty, or when sourceLanguage is not a valid 2-character language code present in the available source languages. + /// Thrown when the API returns an error. Task SearchAsync(string searchTerm, string sourceLanguage, params string [] targetLanguages); /// /// Executes a search request and subsequent calls to load the entry information of the search result. /// + /// The advanced search request containing search parameters + /// Specifies the languages for which translations are returned (if any). When no specific languages are specified, all available translations are returned. + /// Thrown when searchRequest is null. + /// Thrown when searchRequest properties are invalid (e.g., null/empty Language or SearchText, or Language is not a valid 2-character language code present in the available source languages). + /// Thrown when the API returns an error. Task SearchAsync(AdvancedSearchRequest searchRequest, params string[] targetLanguages); /// /// Gets the specified entry. /// /// The entry ID (obtained from a search request) /// Specifies the languages for which translations are returned (if any). When no specific languages are specified, all available translations are returned. - /// + /// Thrown when entryId is null or empty. + /// Thrown when the API returns an error or the entry is not found. Task GetEntryAsync(string entryId, params string[] targetLanguages); } } \ No newline at end of file diff --git a/source/Lexicala.NET/Parsing/LexicalaSearchParser.cs b/source/Lexicala.NET/Parsing/LexicalaSearchParser.cs index 209dd82..e9bb716 100644 --- a/source/Lexicala.NET/Parsing/LexicalaSearchParser.cs +++ b/source/Lexicala.NET/Parsing/LexicalaSearchParser.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Lexicala.NET.Parsing.Dto; using Lexicala.NET.Request; @@ -15,6 +16,10 @@ namespace Lexicala.NET.Parsing /// public class LexicalaSearchParser : ILexicalaSearchParser { + private const int DefaultMaxConcurrentEntryRequests = 4; + private const int LowRateLimitThreshold = 5; + private const int MediumRateLimitThreshold = 20; + private readonly ILexicalaClient _lexicalaClient; private readonly IMemoryCache _memoryCache; @@ -31,6 +36,9 @@ public LexicalaSearchParser(ILexicalaClient lexicalaClient, IMemoryCache memoryC /// public async Task SearchAsync(string searchText, string sourceLanguage, params string[] targetLanguages) { + ArgumentException.ThrowIfNullOrEmpty(searchText, nameof(searchText)); + ArgumentException.ThrowIfNullOrEmpty(sourceLanguage, nameof(sourceLanguage)); + var languages = await LoadLanguages(); if (!languages.Global.SourceLanguages.Contains(sourceLanguage)) { @@ -39,21 +47,25 @@ public async Task SearchAsync(string searchText, string sourc var searchResult = await _lexicalaClient.BasicSearchAsync(searchText.ToLowerInvariant(), sourceLanguage); - return await ProcessSearchResult(searchText, searchResult); + return await ProcessSearchResult(searchText, searchResult, targetLanguages); } /// public async Task SearchAsync(AdvancedSearchRequest searchRequest, params string[] targetLanguages) { + ArgumentNullException.ThrowIfNull(searchRequest); + ArgumentException.ThrowIfNullOrEmpty(searchRequest.Language, nameof(searchRequest.Language)); + ArgumentException.ThrowIfNullOrEmpty(searchRequest.SearchText, nameof(searchRequest.SearchText)); + var languages = await LoadLanguages(); if (!languages.Global.SourceLanguages.Contains(searchRequest.Language)) { - throw new ArgumentException($"Invalid value. '{searchRequest.Language}' does not appear in the Global source languages list"); + throw new ArgumentException($"Invalid value. '{searchRequest.Language}' does not appear in the Global source languages list", nameof(searchRequest.Language)); } var searchResult = await _lexicalaClient.AdvancedSearchAsync(searchRequest); - return await ProcessSearchResult(searchRequest.SearchText, searchResult); + return await ProcessSearchResult(searchRequest.SearchText, searchResult, targetLanguages); } /// @@ -64,30 +76,42 @@ public async Task GetEntryAsync(string entryId, params string } - private async Task ProcessSearchResult(string searchText, SearchResponse searchResult) + private async Task ProcessSearchResult(string searchText, SearchResponse searchResult, string[] targetLanguages) { - var entries = new List(); + // Collect all unique entry IDs to fetch. + var allIds = new HashSet(); foreach (var result in searchResult.Results) { - if (entries.Any(e => e.Id == result.Id)) + allIds.Add(result.Id); + } + + var initialConcurrency = DetermineMaxConcurrency(searchResult.Metadata?.RateLimits?.LimitRemaining ?? -1); + var initialEntries = await FetchEntriesWithConcurrencyAsync(allIds, initialConcurrency); + + // Collect related entry IDs. + var relatedIds = new HashSet(); + foreach (var entry in initialEntries) + { + if (entry.RelatedEntries == null) { continue; } - var entry = await _lexicalaClient.GetEntryAsync(result.Id); - entries.Add(entry); - foreach (var relatedEntry in entry.RelatedEntries) + foreach (var relatedId in entry.RelatedEntries) { - if (entries.Any(e => e.Id == relatedEntry)) + if (!allIds.Contains(relatedId)) { - continue; + relatedIds.Add(relatedId); + allIds.Add(relatedId); } - - var related = await _lexicalaClient.GetEntryAsync(relatedEntry); - entries.Add(related); } } + var relatedConcurrency = DetermineMaxConcurrency(GetLatestKnownRemainingLimit(initialEntries, searchResult.Metadata?.RateLimits?.LimitRemaining ?? -1)); + var relatedEntries = await FetchEntriesWithConcurrencyAsync(relatedIds, relatedConcurrency); + + var entries = initialEntries.Concat(relatedEntries).ToList(); + var returnModel = new SearchResultModel { SearchText = searchText.ToLowerInvariant(), @@ -97,7 +121,7 @@ private async Task ProcessSearchResult(string searchText, Sea foreach (var entry in entries) { - var resultModel = ParseEntry(entry); + var resultModel = ParseEntry(entry, targetLanguages); returnModel.Results.Add(resultModel); } @@ -105,9 +129,69 @@ private async Task ProcessSearchResult(string searchText, Sea return returnModel; } + private async Task> FetchEntriesWithConcurrencyAsync(IEnumerable ids, int maxConcurrency) + { + var idList = ids.ToList(); + if (idList.Count == 0) + { + return []; + } + + var safeConcurrency = Math.Max(1, maxConcurrency); + var entries = new Entry[idList.Count]; + using var gate = new SemaphoreSlim(safeConcurrency); + + var tasks = idList.Select(async (id, index) => + { + await gate.WaitAsync(); + try + { + entries[index] = await _lexicalaClient.GetEntryAsync(id); + } + finally + { + gate.Release(); + } + }); + + await Task.WhenAll(tasks); + return [.. entries.Where(entry => entry != null)]; + } + + private static int DetermineMaxConcurrency(int limitRemaining) + { + if (limitRemaining is >= 0 and <= LowRateLimitThreshold) + { + return 1; + } + + if (limitRemaining > LowRateLimitThreshold && limitRemaining <= MediumRateLimitThreshold) + { + return 2; + } + + return DefaultMaxConcurrentEntryRequests; + } + + private static int GetLatestKnownRemainingLimit(IEnumerable entries, int fallback) + { + foreach (var entry in entries) + { + var remaining = entry.Metadata?.RateLimits?.LimitRemaining ?? -1; + if (remaining >= 0) + { + return remaining; + } + } + + return fallback; + } + private static SearchResultEntry ParseEntry(Entry entry, params string[] targetLanguages) { + // Extract all pronunciations from all headwords into a flat collection + // Entry can have multiple headwords, each with multiple pronunciations var pronunciations = new List(); foreach (var headword in entry.Headwords) { @@ -120,29 +204,37 @@ private static SearchResultEntry ParseEntry(Entry entry, params string[] targetL } } + // Extract gender from first headword that has one + // Gender is a property that may vary across different headword entries string gender = null; foreach (var headword in entry.Headwords) { gender = headword.Gender; - if (gender != null) break; + if (gender != null) break; // Use first available gender } + // Collect all unique parts of speech from all headwords var pos = entry.Headwords.SelectMany(hw => hw.PartOfSpeeches).Distinct().ToList(); + // Build result model with aggregated data from all headwords var resultModel = new SearchResultEntry { ETag = entry.Metadata.ETag, Id = entry.Id, - Pos = string.Join(",", pos), + Pos = string.Join(",", pos), // CSV format for multiple parts of speech SubCategory = string.Join(",", entry.Headwords.Select(hw => hw.Subcategorization)), - Pronunciation = string.Join(",", pronunciations), - Text = string.Join("/", entry.Headwords.Select(hw => hw.Text)), + Pronunciation = string.Join(",", pronunciations), // All pronunciations concatenated + Text = string.Join("/", entry.Headwords.Select(hw => hw.Text)), // Multiple headwords separated by / Gender = gender }; - resultModel.Stems.AddRange(entry.Headwords.SelectMany(hw => hw.AdditionalInflections)); - + // Add any additional inflectional stems from headwords + foreach (var stem in entry.Headwords.SelectMany(hw => hw.AdditionalInflections)) + { + resultModel.Stems.Add(stem); + } + // Extract and add all inflections from all headwords foreach (var infl in entry.Headwords.Select(hw => hw.Inflections)) { if (infl != null) @@ -154,6 +246,7 @@ private static SearchResultEntry ParseEntry(Entry entry, params string[] targetL } } + // Parse senses with translation filtering applied foreach (var sourceSense in entry.Senses) { var targetSense = ParseSense(sourceSense, targetLanguages); @@ -171,30 +264,17 @@ private static Dto.Sense ParseSense(Response.Entries.Sense sourceSense, string[] Definition = sourceSense.Definition }; - targetSense.Synonyms.AddRange(sourceSense.Synonyms); + foreach (var synonym in sourceSense.Synonyms) + { + targetSense.Synonyms.Add(synonym); + } if (sourceSense.Translations != null) { - var translations = new List(); - if (targetLanguages?.Length > 0) + foreach (var translation in FilterTranslations(sourceSense.Translations, targetLanguages)) { - foreach (var languageCode in targetLanguages) - { - if (sourceSense.Translations.ContainsKey(languageCode)) - { - translations.AddRange(ParseTranslation(languageCode, sourceSense.Translations[languageCode])); - } - } + targetSense.Translations.Add(translation); } - else - { - foreach (var sourceSenseTranslation in sourceSense.Translations) - { - translations.AddRange(ParseTranslation(sourceSenseTranslation.Key, sourceSenseTranslation.Value)); - } - } - - targetSense.Translations.AddRange(translations); } foreach (var sourceExample in sourceSense.Examples) @@ -208,26 +288,13 @@ private static Dto.Sense ParseSense(Response.Entries.Sense sourceSense, string[] if (sourceExample.Translations != null) { - if (targetLanguages?.Length > 0) - { - foreach (var languageCode in targetLanguages) - { - if (sourceExample.Translations.ContainsKey(languageCode)) - { - translations.AddRange(ParseTranslation(languageCode, sourceExample.Translations[languageCode])); - } - } - } - else - { - foreach (var sourceExampleTranslation in sourceExample.Translations) - { - translations.AddRange(ParseTranslation(sourceExampleTranslation.Key, sourceExampleTranslation.Value)); - } - } + translations.AddRange(FilterTranslations(sourceExample.Translations, targetLanguages)); } - example.Translations.AddRange(translations); + foreach (var translation in translations) + { + example.Translations.Add(translation); + } targetSense.Examples.Add(example); } @@ -241,26 +308,10 @@ private static Dto.Sense ParseSense(Response.Entries.Sense sourceSense, string[] if (compositionalPhrase.Translations != null) { - var translations = new List(); - if (targetLanguages?.Length > 0) - { - foreach (var languageCode in targetLanguages) - { - if (compositionalPhrase.Translations.ContainsKey(languageCode)) - { - translations.AddRange(ParseTranslation(languageCode, compositionalPhrase.Translations[languageCode])); - } - } - } - else + foreach (var translation in FilterTranslations(compositionalPhrase.Translations, targetLanguages)) { - foreach (var sourceSenseTranslation in compositionalPhrase.Translations) - { - translations.AddRange(ParseTranslation(sourceSenseTranslation.Key, sourceSenseTranslation.Value)); - } + comp.Translations.Add(translation); } - - comp.Translations.AddRange(translations); } foreach (var sourceExample in compositionalPhrase.Examples) @@ -274,27 +325,13 @@ private static Dto.Sense ParseSense(Response.Entries.Sense sourceSense, string[] if (sourceExample.Translations != null) { - if (targetLanguages?.Length > 0) - { - foreach (var languageCode in targetLanguages) - { - if (sourceExample.Translations.ContainsKey(languageCode)) - { - translations.AddRange(ParseTranslation(languageCode, sourceExample.Translations[languageCode])); - } - } - } - else - { - foreach (var sourceExampleTranslation in sourceExample.Translations) - { - translations.AddRange(ParseTranslation(sourceExampleTranslation.Key, - sourceExampleTranslation.Value)); - } - } + translations.AddRange(FilterTranslations(sourceExample.Translations, targetLanguages)); } - example.Translations.AddRange(translations); + foreach (var translation in translations) + { + example.Translations.Add(translation); + } comp.Examples.Add(example); } @@ -310,16 +347,28 @@ private static Dto.Sense ParseSense(Response.Entries.Sense sourceSense, string[] return targetSense; } - private static IEnumerable ParseTranslation(string languageCode, TranslationObject clo) + private static List ParseTranslation(string languageCode, TranslationObject clo) { // json response is a bit flawed: it returns an object for 1 result, or an array for multiple results. this is difficult to deserialize so that's why this line looks a bit strange var translations = (clo.Translation != null - ? new List { new() { Language = languageCode, Text = clo.Translation.Text } } + ? [new Translation { Language = languageCode, Text = clo.Translation.Text }] : clo.Translations?.Select(nl => new Translation { Text = nl.Text, Language = languageCode }).ToList()) - ?? new List(); + ?? []; return translations; } + private static List FilterTranslations(Dictionary translationsDict, string[] targetLanguages) + { + if (targetLanguages?.Length > 0) + { + return [.. targetLanguages + .Where(translationsDict.ContainsKey) + .SelectMany(languageCode => ParseTranslation(languageCode, translationsDict[languageCode]))]; + } + + return [.. translationsDict.SelectMany(kvp => ParseTranslation(kvp.Key, kvp.Value))]; + } + private async Task LoadLanguages() { if (!_memoryCache.TryGetValue("languages", out Resources languages)) diff --git a/source/Lexicala.NET/Request/AdvancedSearchRequest.cs b/source/Lexicala.NET/Request/AdvancedSearchRequest.cs index b36942a..f3ed694 100644 --- a/source/Lexicala.NET/Request/AdvancedSearchRequest.cs +++ b/source/Lexicala.NET/Request/AdvancedSearchRequest.cs @@ -38,6 +38,14 @@ public class AdvancedSearchRequest /// Find multiple sense entries only. /// public bool Polysemous { get; set; } + /// + /// Search for synonyms. + /// + public bool Synonyms { get; set; } + /// + /// Search for antonyms. + /// + public bool Antonyms { get; set; } /// /// Specify grammatical gender ( = masculine, feminine, ...). diff --git a/source/Lexicala.NET/Request/Sources.cs b/source/Lexicala.NET/Request/Sources.cs index 1c1a774..0cf8c95 100644 --- a/source/Lexicala.NET/Request/Sources.cs +++ b/source/Lexicala.NET/Request/Sources.cs @@ -16,6 +16,9 @@ public static class Sources /// /// RANDOM HOUSE WEBSTER’S COLLEGE DICTIONARY. /// - public const string Random = "random"; - } + public const string Random = "random"; + /// + /// MULTIGLOSS MULTILINGUAL DICTIONARY. + /// + public const string Multigloss = "multigloss"; } } \ No newline at end of file diff --git a/source/Lexicala.NET/Response/Entries/AlternativeScripts.cs b/source/Lexicala.NET/Response/Entries/AlternativeScripts.cs index 0c042d9..54ad12b 100644 --- a/source/Lexicala.NET/Response/Entries/AlternativeScripts.cs +++ b/source/Lexicala.NET/Response/Entries/AlternativeScripts.cs @@ -1,17 +1,37 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents an alternative script rendering for a lexical form. + /// + /// + /// The Lexicala structure describes alternative scripts as script-name/text pairs. + /// This model captures a normalized typed representation used by this SDK. + /// public class AlternativeScripts { - [JsonProperty("type")] + /// + /// Gets or sets the alternative script type. + /// + [JsonPropertyName("type")] public TypeEnum Type { get; set; } - [JsonProperty("text")] + /// + /// Gets or sets the text value in the alternative script. + /// + [JsonPropertyName("text")] public string Text { get; set; } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } - public enum TypeEnum { Romaji }; -} \ No newline at end of file + /// + /// Supported alternative script types represented by this SDK. + /// + public enum TypeEnum + { + /// + /// Romanized Japanese representation. + /// + Romaji + } +} diff --git a/source/Lexicala.NET/Response/Entries/AlternativeScriptsObject.cs b/source/Lexicala.NET/Response/Entries/AlternativeScriptsObject.cs index bc4eb7d..d6016a5 100644 --- a/source/Lexicala.NET/Response/Entries/AlternativeScriptsObject.cs +++ b/source/Lexicala.NET/Response/Entries/AlternativeScriptsObject.cs @@ -1,11 +1,32 @@ -namespace Lexicala.NET.Response.Entries +namespace Lexicala.NET.Response.Entries { - public struct AlternativeScriptsObject + /// + /// Represents a flexible JSON shape for alternative scripts. + /// + /// + /// Lexicala can return either a single object or an array for this field. + /// This union-like struct preserves both possibilities for custom converters. + /// + public struct AlternativeScriptsObject { + /// + /// Gets or sets a single alternative script object. + /// public AlternativeScripts AlternativeScripts; + + /// + /// Gets or sets multiple alternative script objects. + /// public AlternativeScripts[] AlternativeScriptsArray; + /// + /// Converts a single value into the wrapper. + /// public static implicit operator AlternativeScriptsObject(AlternativeScripts alternativeScripts) => new() { AlternativeScripts = alternativeScripts }; + + /// + /// Converts an array of values into the wrapper. + /// public static implicit operator AlternativeScriptsObject(AlternativeScripts[] alternativeScriptsArray) => new() { AlternativeScriptsArray = alternativeScriptsArray }; } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/CompositionalPhrase.cs b/source/Lexicala.NET/Response/Entries/CompositionalPhrase.cs index c54d39b..2f0cc1c 100644 --- a/source/Lexicala.NET/Response/Entries/CompositionalPhrase.cs +++ b/source/Lexicala.NET/Response/Entries/CompositionalPhrase.cs @@ -1,31 +1,57 @@ -using System.Collections.Generic; -using Newtonsoft.Json; +using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents a compositional phrase attached to a lexical sense. + /// + /// + /// In Lexicala responses, compositional phrases can contain definition text, + /// optional semantic labels, examples, translations, and nested senses. + /// public class CompositionalPhrase { - [JsonProperty("text")] + /// + /// Gets or sets the compositional phrase text. + /// + [JsonPropertyName("text")] public string Text { get; set; } - [JsonProperty("definition")] + /// + /// Gets or sets the phrase definition. + /// + [JsonPropertyName("definition")] public string Definition { get; set; } - [JsonProperty("semantic_subcategory", NullValueHandling = NullValueHandling.Ignore)] + /// + /// Gets or sets the semantic subcategory label for the phrase. + /// + [JsonPropertyName("semantic_subcategory")] public string SemanticSubcategory { get; set; } - [JsonProperty("senses")] + /// + /// Gets or sets nested senses associated with this compositional phrase. + /// + [JsonPropertyName("senses")] public Sense[] Senses { get; set; } = []; - [JsonProperty("translations")] + /// + /// Gets or sets phrase translations keyed by 2-letter language code. + /// + [JsonPropertyName("translations")] public Dictionary Translations { get; set; } - [JsonProperty("examples")] + /// + /// Gets or sets usage examples for this compositional phrase. + /// + [JsonPropertyName("examples")] public Example[] Examples { get; set; } = []; - [JsonProperty("semantic_category", NullValueHandling = NullValueHandling.Ignore)] + /// + /// Gets or sets the semantic category label for the phrase. + /// + [JsonPropertyName("semantic_category")] public string SemanticCategory { get; set; } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/Entry.cs b/source/Lexicala.NET/Response/Entries/Entry.cs index d62823a..d122119 100644 --- a/source/Lexicala.NET/Response/Entries/Entry.cs +++ b/source/Lexicala.NET/Response/Entries/Entry.cs @@ -1,35 +1,71 @@ -using System; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents a dictionary entry returned by entry-oriented endpoints. + /// + /// + /// Populated by and by search-entry style endpoints + /// returning entry payloads. + /// public class Entry { - [JsonProperty("id")] + /// + /// Gets or sets the unique dictionary entry identifier. + /// + [JsonPropertyName("id")] public string Id { get; set; } - [JsonProperty("source")] + /// + /// Gets or sets the source resource name (for example: global, password, multigloss, random). + /// + [JsonPropertyName("source")] public string Source { get; set; } - [JsonProperty("language")] + /// + /// Gets or sets the 2-letter source language code. + /// + [JsonPropertyName("language")] public string Language { get; set; } - [JsonProperty("version")] + /// + /// Gets or sets the version number of the source lexical resource. + /// + [JsonPropertyName("version")] public int Version { get; set; } - [JsonProperty("frequency")] + /// + /// Gets or sets frequency information derived from corpus data. + /// + [JsonPropertyName("frequency")] public int Frequency { get; set; } - [JsonProperty("headword")] + /// + /// Gets or sets headword data in single-or-array wrapper format. + /// + [JsonPropertyName("headword")] public HeadwordObject HeadwordObject { get; set; } - [JsonProperty("senses")] + /// + /// Gets or sets senses belonging to this entry. + /// + [JsonPropertyName("senses")] public Sense[] Senses { get; set; } = []; - [JsonProperty("related_entries")] + /// + /// Gets or sets related entry identifiers. + /// + [JsonPropertyName("related_entries")] public string[] RelatedEntries { get; set; } = []; + /// + /// Gets normalized headword values as an array. + /// + /// + /// Lexicala may return headword as either one object or an array. + /// This accessor normalizes both shapes to an array. + /// public Headword[] Headwords { get @@ -49,7 +85,14 @@ public Headword[] Headwords } } + /// + /// Gets or sets response header metadata (ETag and rate limits) associated with this entry. + /// + /// + /// The ETag can be reused in conditional requests with If-None-Match to optimize + /// caching behavior. + /// public ResponseMetadata Metadata { get; set; } = new ResponseMetadata(); -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } } + diff --git a/source/Lexicala.NET/Response/Entries/Example.cs b/source/Lexicala.NET/Response/Entries/Example.cs index 9d4e67d..976135c 100644 --- a/source/Lexicala.NET/Response/Entries/Example.cs +++ b/source/Lexicala.NET/Response/Entries/Example.cs @@ -1,14 +1,23 @@ -using System.Collections.Generic; -using Newtonsoft.Json; +using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { + /// + /// Represents a usage example for a sense or compositional phrase. + /// public class Example { - [JsonProperty("text")] + /// + /// Gets or sets the example sentence text. + /// + [JsonPropertyName("text")] public string Text { get; set; } - [JsonProperty("translations", NullValueHandling = NullValueHandling.Ignore)] + /// + /// Gets or sets example translations keyed by 2-letter language code. + /// + [JsonPropertyName("translations")] public Dictionary Translations { get; set; } } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/Headword.cs b/source/Lexicala.NET/Response/Entries/Headword.cs index b23bcc3..c7b38b8 100644 --- a/source/Lexicala.NET/Response/Entries/Headword.cs +++ b/source/Lexicala.NET/Response/Entries/Headword.cs @@ -1,35 +1,71 @@ -using System; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents detailed headword information for an entry. + /// + /// + /// Lexicala headword data includes orthography, part-of-speech, pronunciation, + /// and morphological details such as inflections. + /// public class Headword { - [JsonProperty("text")] + /// + /// Gets or sets the headword surface text. + /// + [JsonPropertyName("text")] public string Text { get; set; } - [JsonProperty("pronunciation")] + /// + /// Gets or sets pronunciation data in single-or-array wrapper format. + /// + [JsonPropertyName("pronunciation")] public PronunciationObject PronunciationObject { get; set; } - [JsonProperty("pos", NullValueHandling = NullValueHandling.Ignore)] + /// + /// Gets or sets part-of-speech data in single-or-array wrapper format. + /// + [JsonPropertyName("pos")] public Pos Pos { get; set; } - [JsonProperty("homograph_number")] + /// + /// Gets or sets the homograph number for this headword. + /// + [JsonPropertyName("homograph_number")] public int HomographNumber { get; set; } - [JsonProperty("subcategorization")] + /// + /// Gets or sets grammatical subcategorization information. + /// + [JsonPropertyName("subcategorization")] public string Subcategorization { get; set; } - [JsonProperty("gender")] + /// + /// Gets or sets grammatical gender, when available. + /// + [JsonPropertyName("gender")] public string Gender { get; set; } - [JsonProperty("inflections")] + /// + /// Gets or sets inflection forms associated with this headword. + /// + [JsonPropertyName("inflections")] public Inflection[] Inflections { get; set; } - [JsonProperty("additional_inflections")] + /// + /// Gets or sets additional inflectional forms as plain text values. + /// + [JsonPropertyName("additional_inflections")] public string[] AdditionalInflections { get; set; } = []; + /// + /// Gets normalized pronunciation values as an array. + /// + /// + /// Lexicala may return pronunciation as either a single object or an array. + /// This accessor normalizes both shapes to an array. + /// public Pronunciation[] Pronunciations { get @@ -49,7 +85,9 @@ public Pronunciation[] Pronunciations } } - public string[] PartOfSpeeches => Pos.PartOfSpeechArray ?? [Pos.PartOfSpeech]; + /// + /// Gets normalized part-of-speech values as an array. + /// + public string[] PartOfSpeeches => Pos.PartOfSpeechArray ?? (Pos.PartOfSpeech != null ? [Pos.PartOfSpeech] : []); } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/HeadwordObject.cs b/source/Lexicala.NET/Response/Entries/HeadwordObject.cs index 13697ae..7486eb1 100644 --- a/source/Lexicala.NET/Response/Entries/HeadwordObject.cs +++ b/source/Lexicala.NET/Response/Entries/HeadwordObject.cs @@ -1,13 +1,31 @@ -namespace Lexicala.NET.Response.Entries +namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents a flexible JSON shape for headword data. + /// + /// + /// Lexicala may return headword as either a single object or an array. + /// public struct HeadwordObject { + /// + /// Gets or sets a single headword object. + /// public Headword Headword; + + /// + /// Gets or sets multiple headword objects. + /// public Headword[] HeadwordElementArray; + /// + /// Converts a single into the wrapper. + /// public static implicit operator HeadwordObject(Headword headword) => new HeadwordObject { Headword = headword }; + + /// + /// Converts an array of values into the wrapper. + /// public static implicit operator HeadwordObject(Headword[] headwordElementArray) => new HeadwordObject { HeadwordElementArray = headwordElementArray }; } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/Inflection.cs b/source/Lexicala.NET/Response/Entries/Inflection.cs index 5684bf5..0f83154 100644 --- a/source/Lexicala.NET/Response/Entries/Inflection.cs +++ b/source/Lexicala.NET/Response/Entries/Inflection.cs @@ -1,18 +1,28 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents an inflected form for a headword or translation. + /// public class Inflection { - [JsonProperty("text")] + /// + /// Gets or sets the inflected surface text. + /// + [JsonPropertyName("text")] public string Text { get; set; } - [JsonProperty("number")] + /// + /// Gets or sets grammatical number for this inflection, when available. + /// + [JsonPropertyName("number")] public string Number { get; set; } - [JsonProperty("tense")] + /// + /// Gets or sets grammatical tense for this inflection, when available. + /// + [JsonPropertyName("tense")] public string Tense { get; set; } } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/JsonConverters/AlternativeScriptsObjectConverter.cs b/source/Lexicala.NET/Response/Entries/JsonConverters/AlternativeScriptsObjectConverter.cs index 03f693b..4f893b3 100644 --- a/source/Lexicala.NET/Response/Entries/JsonConverters/AlternativeScriptsObjectConverter.cs +++ b/source/Lexicala.NET/Response/Entries/JsonConverters/AlternativeScriptsObjectConverter.cs @@ -1,42 +1,41 @@ -using System; -using Newtonsoft.Json; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries.JsonConverters { - internal class AlternativeScriptsObjectConverter : JsonConverter + internal class AlternativeScriptsObjectConverter : JsonConverter { - public override bool CanConvert(Type t) => t == typeof(AlternativeScriptsObject) || t == typeof(AlternativeScriptsObject?); - - public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + public override AlternativeScriptsObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { switch (reader.TokenType) { - case JsonToken.StartObject: - var objectValue = serializer.Deserialize(reader); + case JsonTokenType.StartObject: + var objectValue = JsonSerializer.Deserialize(ref reader, options); return new AlternativeScriptsObject { AlternativeScripts = objectValue }; - case JsonToken.StartArray: - var arrayValue = serializer.Deserialize(reader); + case JsonTokenType.StartArray: + var arrayValue = JsonSerializer.Deserialize(ref reader, options); return new AlternativeScriptsObject { AlternativeScriptsArray = arrayValue }; } - throw new Exception("Cannot unmarshal type AlternativeScriptsObject"); + + throw new JsonException("Cannot unmarshal type AlternativeScriptsObject"); } - public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, AlternativeScriptsObject value, JsonSerializerOptions options) { - var value = (AlternativeScriptsObject)untypedValue; if (value.AlternativeScriptsArray != null) { - serializer.Serialize(writer, value.AlternativeScriptsArray); + JsonSerializer.Serialize(writer, value.AlternativeScriptsArray, options); return; } if (value.AlternativeScripts != null) { - serializer.Serialize(writer, value.AlternativeScripts); + JsonSerializer.Serialize(writer, value.AlternativeScripts, options); return; } - throw new Exception("Cannot marshal type AlternativeScriptsObject"); - } - public static readonly AlternativeScriptsObjectConverter Singleton = new AlternativeScriptsObjectConverter(); + throw new JsonException("Cannot marshal type AlternativeScriptsObject"); + } } -} \ No newline at end of file +} + diff --git a/source/Lexicala.NET/Response/Entries/JsonConverters/EntryResponseJsonConverter.cs b/source/Lexicala.NET/Response/Entries/JsonConverters/EntryResponseJsonConverter.cs index da8edc8..f252c4f 100644 --- a/source/Lexicala.NET/Response/Entries/JsonConverters/EntryResponseJsonConverter.cs +++ b/source/Lexicala.NET/Response/Entries/JsonConverters/EntryResponseJsonConverter.cs @@ -1,25 +1,10 @@ -using System.Collections.Generic; -using System.Globalization; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; +using System.Text.Json; namespace Lexicala.NET.Response.Entries.JsonConverters { - public static class EntryResponseJsonConverter + internal static class EntryResponseJsonConverter { - public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings - { - MetadataPropertyHandling = MetadataPropertyHandling.Ignore, - DateParseHandling = DateParseHandling.None, - Converters = new List - { - TranslationObjectConverter.Singleton, - HeadwordObjectConverter.Singleton, - PronunciationObjectConverter.Singleton, - PosConverter.Singleton, - AlternativeScriptsObjectConverter.Singleton, - new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } - } - }; + internal static readonly JsonSerializerOptions Settings = JsonSerializerDefaults.Options; } -} \ No newline at end of file +} + diff --git a/source/Lexicala.NET/Response/Entries/JsonConverters/FlexibleIntConverter.cs b/source/Lexicala.NET/Response/Entries/JsonConverters/FlexibleIntConverter.cs new file mode 100644 index 0000000..8e85e2c --- /dev/null +++ b/source/Lexicala.NET/Response/Entries/JsonConverters/FlexibleIntConverter.cs @@ -0,0 +1,29 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Lexicala.NET.Response.Entries.JsonConverters +{ + internal class FlexibleIntConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var intValue)) + { + return intValue; + } + + if (reader.TokenType == JsonTokenType.String && int.TryParse(reader.GetString(), out var parsedValue)) + { + return parsedValue; + } + + throw new JsonException("Cannot convert JSON value to int."); + } + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value); + } + } +} diff --git a/source/Lexicala.NET/Response/Entries/JsonConverters/HeadwordObjectConverter.cs b/source/Lexicala.NET/Response/Entries/JsonConverters/HeadwordObjectConverter.cs index 014270a..76021b9 100644 --- a/source/Lexicala.NET/Response/Entries/JsonConverters/HeadwordObjectConverter.cs +++ b/source/Lexicala.NET/Response/Entries/JsonConverters/HeadwordObjectConverter.cs @@ -1,42 +1,41 @@ -using System; -using Newtonsoft.Json; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries.JsonConverters { - internal class HeadwordObjectConverter : JsonConverter + internal class HeadwordObjectConverter : JsonConverter { - public override bool CanConvert(Type t) => t == typeof(HeadwordObject) || t == typeof(HeadwordObject?); - - public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + public override HeadwordObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { switch (reader.TokenType) { - case JsonToken.StartObject: - var objectValue = serializer.Deserialize(reader); + case JsonTokenType.StartObject: + var objectValue = JsonSerializer.Deserialize(ref reader, options); return new HeadwordObject { Headword = objectValue }; - case JsonToken.StartArray: - var arrayValue = serializer.Deserialize(reader); + case JsonTokenType.StartArray: + var arrayValue = JsonSerializer.Deserialize(ref reader, options); return new HeadwordObject { HeadwordElementArray = arrayValue }; } - throw new Exception("Cannot unmarshal type HeadwordObject"); + + throw new JsonException("Cannot unmarshal type HeadwordObject"); } - public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, HeadwordObject value, JsonSerializerOptions options) { - var value = (HeadwordObject)untypedValue; if (value.HeadwordElementArray != null) { - serializer.Serialize(writer, value.HeadwordElementArray); + JsonSerializer.Serialize(writer, value.HeadwordElementArray, options); return; } if (value.Headword != null) { - serializer.Serialize(writer, value.Headword); + JsonSerializer.Serialize(writer, value.Headword, options); return; } - throw new Exception("Cannot marshal type HeadwordObject"); - } - public static readonly HeadwordObjectConverter Singleton = new HeadwordObjectConverter(); + throw new JsonException("Cannot marshal type HeadwordObject"); + } } -} \ No newline at end of file +} + diff --git a/source/Lexicala.NET/Response/Entries/JsonConverters/PosConverter.cs b/source/Lexicala.NET/Response/Entries/JsonConverters/PosConverter.cs index dc7e7dc..b5d66d3 100644 --- a/source/Lexicala.NET/Response/Entries/JsonConverters/PosConverter.cs +++ b/source/Lexicala.NET/Response/Entries/JsonConverters/PosConverter.cs @@ -1,43 +1,41 @@ -using System; -using Newtonsoft.Json; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries.JsonConverters { - internal class PosConverter : JsonConverter + internal class PosConverter : JsonConverter { - public override bool CanConvert(Type t) => t == typeof(Pos) || t == typeof(Pos?); - - public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + public override Pos Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { switch (reader.TokenType) { - case JsonToken.String: - case JsonToken.Date: - var stringValue = serializer.Deserialize(reader); + case JsonTokenType.String: + var stringValue = reader.GetString(); return new Pos { PartOfSpeech = stringValue }; - case JsonToken.StartArray: - var arrayValue = serializer.Deserialize(reader); + case JsonTokenType.StartArray: + var arrayValue = JsonSerializer.Deserialize(ref reader, options); return new Pos { PartOfSpeechArray = arrayValue }; } - throw new Exception("Cannot unmarshal type PartOfSpeech"); + + throw new JsonException("Cannot unmarshal type PartOfSpeech"); } - public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, Pos value, JsonSerializerOptions options) { - var value = (Pos)untypedValue; if (value.PartOfSpeech != null) { - serializer.Serialize(writer, value.PartOfSpeech); + JsonSerializer.Serialize(writer, value.PartOfSpeech, options); return; } if (value.PartOfSpeechArray != null) { - serializer.Serialize(writer, value.PartOfSpeechArray); + JsonSerializer.Serialize(writer, value.PartOfSpeechArray, options); return; } - throw new Exception("Cannot marshal type PartOfSpeech"); - } - public static readonly PosConverter Singleton = new PosConverter(); + throw new JsonException("Cannot marshal type PartOfSpeech"); + } } -} \ No newline at end of file +} + diff --git a/source/Lexicala.NET/Response/Entries/JsonConverters/PronunciationObjectConverter.cs b/source/Lexicala.NET/Response/Entries/JsonConverters/PronunciationObjectConverter.cs index 068987e..046c77b 100644 --- a/source/Lexicala.NET/Response/Entries/JsonConverters/PronunciationObjectConverter.cs +++ b/source/Lexicala.NET/Response/Entries/JsonConverters/PronunciationObjectConverter.cs @@ -1,42 +1,41 @@ -using System; -using Newtonsoft.Json; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries.JsonConverters { - internal class PronunciationObjectConverter : JsonConverter + internal class PronunciationObjectConverter : JsonConverter { - public override bool CanConvert(Type t) => t == typeof(PronunciationObject) || t == typeof(PronunciationObject?); - - public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + public override PronunciationObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { switch (reader.TokenType) { - case JsonToken.StartObject: - var objectValue = serializer.Deserialize(reader); + case JsonTokenType.StartObject: + var objectValue = JsonSerializer.Deserialize(ref reader, options); return new PronunciationObject { Pronunciation = objectValue }; - case JsonToken.StartArray: - var arrayValue = serializer.Deserialize(reader); + case JsonTokenType.StartArray: + var arrayValue = JsonSerializer.Deserialize(ref reader, options); return new PronunciationObject { PronunciationArray = arrayValue }; } - throw new Exception("Cannot unmarshal type PronunciationObject"); + + throw new JsonException("Cannot unmarshal type PronunciationObject"); } - public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, PronunciationObject value, JsonSerializerOptions options) { - var value = (PronunciationObject)untypedValue; if (value.PronunciationArray != null) { - serializer.Serialize(writer, value.PronunciationArray); + JsonSerializer.Serialize(writer, value.PronunciationArray, options); return; } if (value.Pronunciation != null) { - serializer.Serialize(writer, value.Pronunciation); + JsonSerializer.Serialize(writer, value.Pronunciation, options); return; } - throw new Exception("Cannot marshal type PronunciationObject"); - } - public static readonly PronunciationObjectConverter Singleton = new PronunciationObjectConverter(); + throw new JsonException("Cannot marshal type PronunciationObject"); + } } -} \ No newline at end of file +} + diff --git a/source/Lexicala.NET/Response/Entries/JsonConverters/TranslationObjectConverter.cs b/source/Lexicala.NET/Response/Entries/JsonConverters/TranslationObjectConverter.cs index d5ccd57..2711542 100644 --- a/source/Lexicala.NET/Response/Entries/JsonConverters/TranslationObjectConverter.cs +++ b/source/Lexicala.NET/Response/Entries/JsonConverters/TranslationObjectConverter.cs @@ -1,42 +1,41 @@ -using System; -using Newtonsoft.Json; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries.JsonConverters { - internal class TranslationObjectConverter : JsonConverter + internal class TranslationObjectConverter : JsonConverter { - public override bool CanConvert(Type t) => t == typeof(TranslationObject) || t == typeof(TranslationObject?); - - public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + public override TranslationObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { switch (reader.TokenType) { - case JsonToken.StartObject: - var objectValue = serializer.Deserialize(reader); + case JsonTokenType.StartObject: + var objectValue = JsonSerializer.Deserialize(ref reader, options); return new TranslationObject { Translation = objectValue }; - case JsonToken.StartArray: - var arrayValue = serializer.Deserialize(reader); + case JsonTokenType.StartArray: + var arrayValue = JsonSerializer.Deserialize(ref reader, options); return new TranslationObject { Translations = arrayValue }; } - throw new Exception("Cannot unmarshal type TranslationObject"); + + throw new JsonException("Cannot unmarshal type TranslationObject"); } - public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, TranslationObject value, JsonSerializerOptions options) { - var value = (TranslationObject)untypedValue; if (value.Translations != null) { - serializer.Serialize(writer, value.Translations); + JsonSerializer.Serialize(writer, value.Translations, options); return; } if (value.Translation != null) { - serializer.Serialize(writer, value.Translation); + JsonSerializer.Serialize(writer, value.Translation, options); return; } - throw new Exception("Cannot marshal type TranslationObject"); - } - public static readonly TranslationObjectConverter Singleton = new TranslationObjectConverter(); + throw new JsonException("Cannot marshal type TranslationObject"); + } } -} \ No newline at end of file +} + diff --git a/source/Lexicala.NET/Response/Entries/Pos.cs b/source/Lexicala.NET/Response/Entries/Pos.cs index db5e052..0a18741 100644 --- a/source/Lexicala.NET/Response/Entries/Pos.cs +++ b/source/Lexicala.NET/Response/Entries/Pos.cs @@ -1,14 +1,32 @@ -namespace Lexicala.NET.Response.Entries +namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents a flexible JSON shape for part-of-speech values. + /// + /// + /// Lexicala may return a single POS string or an array of strings. + /// public struct Pos { + /// + /// Gets or sets a single part-of-speech value. + /// public string PartOfSpeech; + + /// + /// Gets or sets multiple part-of-speech values. + /// public string[] PartOfSpeechArray; + /// + /// Converts a single part-of-speech value into the wrapper. + /// public static implicit operator Pos(string pos) => new Pos { PartOfSpeech = pos }; + + /// + /// Converts multiple part-of-speech values into the wrapper. + /// public static implicit operator Pos(string[] posArray) => new Pos { PartOfSpeechArray = posArray }; } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/Pronunciation.cs b/source/Lexicala.NET/Response/Entries/Pronunciation.cs index d24c214..5e951fa 100644 --- a/source/Lexicala.NET/Response/Entries/Pronunciation.cs +++ b/source/Lexicala.NET/Response/Entries/Pronunciation.cs @@ -1,11 +1,16 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { + /// + /// Represents a pronunciation value for a lexical item. + /// public class Pronunciation { - [JsonProperty("value")] + /// + /// Gets or sets the pronunciation text. + /// + [JsonPropertyName("value")] public string Value { get; set; } } - -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/PronunciationObject.cs b/source/Lexicala.NET/Response/Entries/PronunciationObject.cs index dc4ad62..8c47814 100644 --- a/source/Lexicala.NET/Response/Entries/PronunciationObject.cs +++ b/source/Lexicala.NET/Response/Entries/PronunciationObject.cs @@ -1,13 +1,31 @@ -namespace Lexicala.NET.Response.Entries +namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents a flexible JSON shape for pronunciation values. + /// + /// + /// Lexicala may return pronunciation as either a single object or an array. + /// public struct PronunciationObject { + /// + /// Gets or sets a single pronunciation object. + /// public Pronunciation Pronunciation; + + /// + /// Gets or sets multiple pronunciation objects. + /// public Pronunciation[] PronunciationArray; + /// + /// Converts a single into the wrapper. + /// public static implicit operator PronunciationObject(Pronunciation pronunciation) => new PronunciationObject { Pronunciation = pronunciation }; + + /// + /// Converts multiple values into the wrapper. + /// public static implicit operator PronunciationObject(Pronunciation[] pronunciationArray) => new PronunciationObject { PronunciationArray = pronunciationArray }; } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/Sense.cs b/source/Lexicala.NET/Response/Entries/Sense.cs index 6d3a3f5..0ee6d3b 100644 --- a/source/Lexicala.NET/Response/Entries/Sense.cs +++ b/source/Lexicala.NET/Response/Entries/Sense.cs @@ -1,44 +1,84 @@ -using System.Collections.Generic; -using Newtonsoft.Json; +using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents a lexical sense within an entry or from direct sense retrieval. + /// + /// + /// Populated as part of and by + /// for direct sense fetches. + /// public class Sense { - [JsonProperty("id")] + /// + /// Gets or sets the unique sense identifier. + /// + [JsonPropertyName("id")] public string Id { get; set; } - [JsonProperty("definition")] + /// + /// Gets or sets the sense definition text. + /// + [JsonPropertyName("definition")] public string Definition { get; set; } - [JsonProperty("range_of_application", NullValueHandling = NullValueHandling.Ignore)] + /// + /// Gets or sets range-of-application information for this sense. + /// + [JsonPropertyName("range_of_application")] public string RangeOfApplication { get; set; } - [JsonProperty("antonyms", NullValueHandling = NullValueHandling.Ignore)] - + /// + /// Gets or sets antonyms associated with this sense. + /// + [JsonPropertyName("antonyms")] public string[] Antonyms { get; set; } = []; - [JsonProperty("synonyms", NullValueHandling = NullValueHandling.Ignore)] + /// + /// Gets or sets synonyms associated with this sense. + /// + [JsonPropertyName("synonyms")] public string[] Synonyms { get; set; } = []; - [JsonProperty("translations")] + /// + /// Gets or sets translations keyed by 2-letter language code. + /// + [JsonPropertyName("translations")] public Dictionary Translations { get; set; } = []; - [JsonProperty("examples")] + /// + /// Gets or sets usage examples for this sense. + /// + [JsonPropertyName("examples")] public Example[] Examples { get; set; } = []; - [JsonProperty("compositional_phrases")] + /// + /// Gets or sets compositional phrases linked to this sense. + /// + [JsonPropertyName("compositional_phrases")] public CompositionalPhrase[] CompositionalPhrases { get; set; } = []; - - [JsonProperty("semantic_subcategory", NullValueHandling = NullValueHandling.Ignore)] + + /// + /// Gets or sets semantic subcategory information. + /// + [JsonPropertyName("semantic_subcategory")] public string SemanticSubcategory { get; set; } - [JsonProperty("geographical_usage", NullValueHandling = NullValueHandling.Ignore)] + /// + /// Gets or sets geographical usage information. + /// + [JsonPropertyName("geographical_usage")] public string GeographicalUsage { get; set; } + /// + /// Gets or sets response header metadata for direct sense retrieval calls. + /// + /// + /// This value is typically populated for top-level sense responses and may be null for + /// nested senses that are part of an entry payload. + /// public ResponseMetadata Metadata { get; set; } - -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/Translation.cs b/source/Lexicala.NET/Response/Entries/Translation.cs index 483d07e..5b9e274 100644 --- a/source/Lexicala.NET/Response/Entries/Translation.cs +++ b/source/Lexicala.NET/Response/Entries/Translation.cs @@ -1,21 +1,34 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents a translation value for a target language. + /// public class Translation { - [JsonProperty("text")] + /// + /// Gets or sets the translated text. + /// + [JsonPropertyName("text")] public string Text { get; set; } - - [JsonProperty("gender")] + + /// + /// Gets or sets grammatical gender for this translation, when available. + /// + [JsonPropertyName("gender")] public string Gender { get; set; } - - [JsonProperty("inflections", NullValueHandling = NullValueHandling.Ignore)] + + /// + /// Gets or sets inflections associated with this translation. + /// + [JsonPropertyName("inflections")] public Inflection[] Inflections { get; set; } - [JsonProperty("alternative_scripts", NullValueHandling = NullValueHandling.Ignore)] - public AlternativeScripts [] AlternativeScripts { get; set; } + /// + /// Gets or sets alternative script values for this translation. + /// + [JsonPropertyName("alternative_scripts")] + public AlternativeScripts[] AlternativeScripts { get; set; } } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Entries/TranslationObject.cs b/source/Lexicala.NET/Response/Entries/TranslationObject.cs index 58216c8..39a4c68 100644 --- a/source/Lexicala.NET/Response/Entries/TranslationObject.cs +++ b/source/Lexicala.NET/Response/Entries/TranslationObject.cs @@ -1,14 +1,32 @@ -namespace Lexicala.NET.Response.Entries +namespace Lexicala.NET.Response.Entries { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents a flexible JSON shape for translation values. + /// + /// + /// Lexicala translation buckets can contain either a single translation object + /// or an array of translations. + /// public struct TranslationObject { + /// + /// Gets or sets a single translation object. + /// public Translation Translation; + + /// + /// Gets or sets multiple translation objects. + /// public Translation[] Translations; + /// + /// Converts a single into the wrapper. + /// public static implicit operator TranslationObject(Translation translation) => new() { Translation = translation }; + + /// + /// Converts multiple values into the wrapper. + /// public static implicit operator TranslationObject(Translation[] translationObjectArray) => new() { Translations = translationObjectArray }; } - -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Languages/LanguagesResponse.cs b/source/Lexicala.NET/Response/Languages/LanguagesResponse.cs index 207c118..12441bd 100644 --- a/source/Lexicala.NET/Response/Languages/LanguagesResponse.cs +++ b/source/Lexicala.NET/Response/Languages/LanguagesResponse.cs @@ -1,16 +1,24 @@ -using System.Collections.Generic; -using Newtonsoft.Json; +using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Languages { #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents the response payload returned by the languages endpoint. + /// + /// + /// Populated by and contains language display names + /// plus source-language/resource metadata. + /// public class LanguagesResponse { - [JsonProperty("language_names")] + [JsonPropertyName("language_names")] public Dictionary LanguageNames { get; set; } - [JsonProperty("resources")] + [JsonPropertyName("resources")] public Resources Resources { get; set; } } #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } + diff --git a/source/Lexicala.NET/Response/Languages/Resource.cs b/source/Lexicala.NET/Response/Languages/Resource.cs index b496ebe..f524315 100644 --- a/source/Lexicala.NET/Response/Languages/Resource.cs +++ b/source/Lexicala.NET/Response/Languages/Resource.cs @@ -1,13 +1,22 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Languages { + /// + /// Represents source and target languages available for a resource. + /// public class Resource { - [JsonProperty("source_languages")] + /// + /// Gets or sets the available source language codes. + /// + [JsonPropertyName("source_languages")] public string[] SourceLanguages { get; set; } - [JsonProperty("target_languages")] + /// + /// Gets or sets the available target language codes. + /// + [JsonPropertyName("target_languages")] public string[] TargetLanguages { get; set; } } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Languages/Resources.cs b/source/Lexicala.NET/Response/Languages/Resources.cs index 4ced505..9e1d284 100644 --- a/source/Lexicala.NET/Response/Languages/Resources.cs +++ b/source/Lexicala.NET/Response/Languages/Resources.cs @@ -1,18 +1,18 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Languages { #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member public class Resources { - [JsonProperty("global")] + [JsonPropertyName("global")] public Resource Global { get; set; } - [JsonProperty("password")] + [JsonPropertyName("password")] public Resource Password { get; set; } - [JsonProperty("random")] + [JsonPropertyName("random")] public Resource Random { get; set; } } #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Me/Activation.cs b/source/Lexicala.NET/Response/Me/Activation.cs deleted file mode 100644 index 66a8c83..0000000 --- a/source/Lexicala.NET/Response/Me/Activation.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Newtonsoft.Json; - -namespace Lexicala.NET.Response.Me -{ -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - public class Activation - { - [JsonProperty("activated")] - public bool Activated { get; set; } - - [JsonProperty("agreed_terms_of_use")] - public bool AgreedTermsOfUse { get; set; } - } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file diff --git a/source/Lexicala.NET/Response/Me/MeResponse.cs b/source/Lexicala.NET/Response/Me/MeResponse.cs deleted file mode 100644 index 6de89ff..0000000 --- a/source/Lexicala.NET/Response/Me/MeResponse.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Newtonsoft.Json; - -namespace Lexicala.NET.Response.Me -{ -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - public class MeResponse - { - [JsonProperty("username")] - public string Username { get; set; } - - [JsonProperty("email")] - public string Email { get; set; } - - [JsonProperty("permissions")] - public Permissions Permissions { get; set; } - - [JsonProperty("usage")] - public Usage Usage { get; set; } - } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} diff --git a/source/Lexicala.NET/Response/Me/Permissions.cs b/source/Lexicala.NET/Response/Me/Permissions.cs deleted file mode 100644 index 105f9d6..0000000 --- a/source/Lexicala.NET/Response/Me/Permissions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace Lexicala.NET.Response.Me -{ -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - public class Permissions - { - [JsonProperty("activation")] - public Activation Activation { get; set; } - - [JsonProperty("pro")] - public bool Pro { get; set; } - - [JsonProperty("enterprise")] - public bool Enterprise { get; set; } - - [JsonProperty("requests_per_day")] - public long RequestsPerDay { get; set; } - - [JsonProperty("creation_date")] - public DateTimeOffset CreationDate { get; set; } - } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file diff --git a/source/Lexicala.NET/Response/Me/Today.cs b/source/Lexicala.NET/Response/Me/Today.cs deleted file mode 100644 index 9ed6524..0000000 --- a/source/Lexicala.NET/Response/Me/Today.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace Lexicala.NET.Response.Me -{ -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - public class Today - { - [JsonProperty("count")] - public long Count { get; set; } - - [JsonProperty("ends_at")] - public DateTimeOffset EndsAt { get; set; } - } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file diff --git a/source/Lexicala.NET/Response/Me/Usage.cs b/source/Lexicala.NET/Response/Me/Usage.cs deleted file mode 100644 index 0dca771..0000000 --- a/source/Lexicala.NET/Response/Me/Usage.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace Lexicala.NET.Response.Me -{ -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - public class Usage - { - [JsonProperty("today")] - public Today Today { get; set; } - - [JsonProperty("lifetime")] - public long Lifetime { get; set; } - - [JsonProperty("history")] - public Dictionary History { get; set; } - } -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file diff --git a/source/Lexicala.NET/Response/RateLimits.cs b/source/Lexicala.NET/Response/RateLimits.cs index e57e98d..082eebd 100644 --- a/source/Lexicala.NET/Response/RateLimits.cs +++ b/source/Lexicala.NET/Response/RateLimits.cs @@ -1,29 +1,32 @@ -namespace Lexicala.NET.Response +namespace Lexicala.NET.Response { /// - /// Rate limit info. + /// Represents rate limit values returned by Lexicala response headers. /// /// - /// The rate limit info is returned as response headers on each response. + /// These values are extracted from response headers by . + /// If a header is missing or unparseable, the corresponding value can be -1. /// public class RateLimits { /// - /// Gets or sets the limit (based on your subscription). + /// Gets or sets the total request limit for the active billing window. /// /// /// This is a static value indicating the number of requests the plan you are currently subscribed to allows you to make before incurring overages. /// public int Limit { get; set; } + /// - /// Gets or sets the remaining amount of allowed api calls. + /// Gets or sets the remaining number of allowed API calls in the current billing window. /// /// /// The number of requests remaining (from your plan) before you reach the limit of requests your application is allowed to make. When this reaches zero, you will begin experiencing overage charges. This will reset each day or each month, depending on how the API pricing plan is configured. You can view these limits and quotas on the pricing page of the API in the API Hub. /// public int LimitRemaining { get; set; } + /// - /// Gets or sets the reset time of the limit in seconds. + /// Gets or sets the number of seconds until the rate limit window resets. /// /// ///

Indicates the number of seconds until the quota resets. This number of seconds would at most be as long as either a day or a month, depending on how the plan was configured.

@@ -40,3 +43,4 @@ public class RateLimits public long Reset { get; set; } } } + diff --git a/source/Lexicala.NET/Response/ResponseHeaders.cs b/source/Lexicala.NET/Response/ResponseHeaders.cs index 2710c2d..e29c757 100644 --- a/source/Lexicala.NET/Response/ResponseHeaders.cs +++ b/source/Lexicala.NET/Response/ResponseHeaders.cs @@ -1,4 +1,4 @@ -namespace Lexicala.NET.Response +namespace Lexicala.NET.Response { /// /// Contains response header constants. @@ -14,10 +14,24 @@ public static class ResponseHeaders /// public const string HeaderRateLimitRequestsRemaining = "X-RateLimit-requests-Remaining"; + /// + /// The number of seconds until the request quota resets. + /// public const string HeaderRateLimitReset = "X-RateLimit-requests-Reset"; + /// + /// The hard-limit quota for free plans. + /// public const string HeaderRapidFreePlanHardLimitLimit = "X-RateLimit-rapid-free-plans-hard-limit-Limit"; + + /// + /// The remaining hard-limit quota for free plans. + /// public const string HeaderRapidFreePlanHardLimitRemaining = "X-RateLimit-rapid-free-plans-hard-limit-Remaining"; + + /// + /// The hard-limit reset time for free plans. + /// public const string HeaderRapidFreePlanHardLimitReset = "X-RateLimit-rapid-free-plans-hard-limit-Reset"; } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/ResponseMetadata.cs b/source/Lexicala.NET/Response/ResponseMetadata.cs index 814c565..912314a 100644 --- a/source/Lexicala.NET/Response/ResponseMetadata.cs +++ b/source/Lexicala.NET/Response/ResponseMetadata.cs @@ -1,17 +1,26 @@ -namespace Lexicala.NET.Response +namespace Lexicala.NET.Response { /// - /// This class contains metadata information that is returned as response headers for each request. + /// Represents metadata extracted from HTTP response headers. /// + /// + /// This metadata is populated by for response types returned by + /// endpoint methods such as search, entry retrieval, and sense retrieval. + /// public class ResponseMetadata { /// - /// Gets or sets the ETag. + /// Gets or sets the ETag value from the response headers. + /// Use this value for conditional requests by passing it back as If-None-Match + /// in subsequent calls. /// public string ETag { get; set; } + /// - /// Gets or sets the rate limits info. + /// Gets or sets parsed rate limit metadata from response headers. + /// Values may be -1 when headers are missing or not parseable. /// public RateLimits RateLimits { get; set; } } } + diff --git a/source/Lexicala.NET/Response/Search/Headword.cs b/source/Lexicala.NET/Response/Search/Headword.cs index 665e538..e5d7b9f 100644 --- a/source/Lexicala.NET/Response/Search/Headword.cs +++ b/source/Lexicala.NET/Response/Search/Headword.cs @@ -1,13 +1,22 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Search { + /// + /// Represents a headword item in a search response. + /// public class Headword { - [JsonProperty("text")] + /// + /// Gets or sets the headword text. + /// + [JsonPropertyName("text")] public string Text { get; set; } - [JsonProperty("pos")] + /// + /// Gets or sets the part of speech. + /// + [JsonPropertyName("pos")] public string Pos { get; set; } } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Search/HeadwordObject.cs b/source/Lexicala.NET/Response/Search/HeadwordObject.cs index a3bba6e..d053886 100644 --- a/source/Lexicala.NET/Response/Search/HeadwordObject.cs +++ b/source/Lexicala.NET/Response/Search/HeadwordObject.cs @@ -1,11 +1,30 @@ -namespace Lexicala.NET.Response.Search +namespace Lexicala.NET.Response.Search { + /// + /// Represents a polymorphic headword value that can be either a single headword or an array. + /// public struct HeadwordObject { + /// + /// A single headword value. + /// public Headword Headword; + + /// + /// A list of headword values. + /// public Headword[] HeadwordElementArray; + /// + /// Converts a to a . + /// + /// The source headword. public static implicit operator HeadwordObject(Headword headword) => new HeadwordObject { Headword = headword }; + + /// + /// Converts an array of values to a . + /// + /// The source headword array. public static implicit operator HeadwordObject(Headword[] headwordElementArray) => new HeadwordObject { HeadwordElementArray = headwordElementArray }; } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Search/HeadwordObjectConverter.cs b/source/Lexicala.NET/Response/Search/HeadwordObjectConverter.cs index e0f03a6..d21b17e 100644 --- a/source/Lexicala.NET/Response/Search/HeadwordObjectConverter.cs +++ b/source/Lexicala.NET/Response/Search/HeadwordObjectConverter.cs @@ -1,42 +1,41 @@ -using System; -using Newtonsoft.Json; +using System; +using System.Text.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Search { - internal class HeadwordObjectConverter : JsonConverter + internal class HeadwordObjectConverter : JsonConverter { - public override bool CanConvert(Type t) => t == typeof(HeadwordObject) || t == typeof(HeadwordObject?); - - public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + public override HeadwordObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { switch (reader.TokenType) { - case JsonToken.StartObject: - var objectValue = serializer.Deserialize(reader); + case JsonTokenType.StartObject: + var objectValue = JsonSerializer.Deserialize(ref reader, options); return new HeadwordObject { Headword = objectValue }; - case JsonToken.StartArray: - var arrayValue = serializer.Deserialize(reader); + case JsonTokenType.StartArray: + var arrayValue = JsonSerializer.Deserialize(ref reader, options); return new HeadwordObject { HeadwordElementArray = arrayValue }; } - throw new Exception("Cannot unmarshal type HeadwordObject"); + + throw new JsonException("Cannot unmarshal type HeadwordObject"); } - public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, HeadwordObject value, JsonSerializerOptions options) { - var value = (HeadwordObject)untypedValue; if (value.HeadwordElementArray != null) { - serializer.Serialize(writer, value.HeadwordElementArray); + JsonSerializer.Serialize(writer, value.HeadwordElementArray, options); return; } if (value.Headword != null) { - serializer.Serialize(writer, value.Headword); + JsonSerializer.Serialize(writer, value.Headword, options); return; } - throw new Exception("Cannot marshal type HeadwordObject"); - } - public static readonly HeadwordObjectConverter Singleton = new HeadwordObjectConverter(); + throw new JsonException("Cannot marshal type HeadwordObject"); + } } -} \ No newline at end of file +} + diff --git a/source/Lexicala.NET/Response/Search/Result.cs b/source/Lexicala.NET/Response/Search/Result.cs index d16756c..d3ccbd1 100644 --- a/source/Lexicala.NET/Response/Search/Result.cs +++ b/source/Lexicala.NET/Response/Search/Result.cs @@ -1,22 +1,22 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Search { #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member public class Result { - [JsonProperty("id")] + [JsonPropertyName("id")] public string Id { get; set; } - [JsonProperty("language")] + [JsonPropertyName("language")] public string Language { get; set; } - [JsonProperty("headword")] + [JsonPropertyName("headword")] public HeadwordObject Headword { get; set; } - [JsonProperty("senses")] - public Sense[] Senses { get; set; } = { }; + [JsonPropertyName("senses")] + public Sense[] Senses { get; set; } = []; } #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Search/SearchResponse.cs b/source/Lexicala.NET/Response/Search/SearchResponse.cs index ec671af..a695f11 100644 --- a/source/Lexicala.NET/Response/Search/SearchResponse.cs +++ b/source/Lexicala.NET/Response/Search/SearchResponse.cs @@ -1,29 +1,46 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Search { #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + /// + /// Represents the response payload for search endpoints. + /// + /// + /// Populated by , + /// , + /// , and + /// . + /// public class SearchResponse { - [JsonProperty("n_results")] + [JsonPropertyName("n_results")] public int NResults { get; set; } - [JsonProperty("page_number")] + [JsonPropertyName("page_number")] public int PageNumber { get; set; } - [JsonProperty("results_per_page")] + [JsonPropertyName("results_per_page")] public int ResultsPerPage { get; set; } - [JsonProperty("n_pages")] + [JsonPropertyName("n_pages")] public int NPages { get; set; } - [JsonProperty("available_n_pages")] + [JsonPropertyName("available_n_pages")] public int AvailableNPages { get; set; } - [JsonProperty("results")] - public Result[] Results { get; set; } = { }; + [JsonPropertyName("results")] + public Result[] Results { get; set; } = []; + /// + /// Gets or sets response header metadata (ETag and rate limits) for this search result. + /// + /// + /// Use to issue conditional requests, and + /// to observe request quota usage. + /// public ResponseMetadata Metadata { get; set; } = new ResponseMetadata(); } #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member } + diff --git a/source/Lexicala.NET/Response/Search/SearchResponseJsonConverter.cs b/source/Lexicala.NET/Response/Search/SearchResponseJsonConverter.cs index cacca1f..436dbbf 100644 --- a/source/Lexicala.NET/Response/Search/SearchResponseJsonConverter.cs +++ b/source/Lexicala.NET/Response/Search/SearchResponseJsonConverter.cs @@ -1,20 +1,16 @@ -using System.Globalization; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; +using System.Text.Json; namespace Lexicala.NET.Response.Search { - public static class SearchResponseJsonConverter + /// + /// Provides serializer settings used for search response conversion. + /// + internal static class SearchResponseJsonConverter { - public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings - { - MetadataPropertyHandling = MetadataPropertyHandling.Ignore, - DateParseHandling = DateParseHandling.None, - Converters = - { - HeadwordObjectConverter.Singleton, - new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } - }, - }; + /// + /// Gets JSON serializer options used to deserialize search responses. + /// + internal static readonly JsonSerializerOptions Settings = JsonSerializerDefaults.Options; } -} \ No newline at end of file +} + diff --git a/source/Lexicala.NET/Response/Search/Sense.cs b/source/Lexicala.NET/Response/Search/Sense.cs index acc5f3d..a8ad6ae 100644 --- a/source/Lexicala.NET/Response/Search/Sense.cs +++ b/source/Lexicala.NET/Response/Search/Sense.cs @@ -1,13 +1,22 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Search { + /// + /// Represents a sense summary in search results. + /// public class Sense { - [JsonProperty("id")] + /// + /// Gets or sets the sense identifier. + /// + [JsonPropertyName("id")] public string Id { get; set; } - [JsonProperty("definition")] + /// + /// Gets or sets the sense definition text. + /// + [JsonPropertyName("definition")] public string Definition { get; set; } } -} \ No newline at end of file +} diff --git a/source/Lexicala.NET/Response/Test/TestResponse.cs b/source/Lexicala.NET/Response/Test/TestResponse.cs index 5346065..96a213c 100644 --- a/source/Lexicala.NET/Response/Test/TestResponse.cs +++ b/source/Lexicala.NET/Response/Test/TestResponse.cs @@ -1,12 +1,19 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Lexicala.NET.Response.Test { #pragma warning disable 1591 + /// + /// Represents the health-check response payload from the test endpoint. + /// + /// + /// Populated by . + /// public class TestResponse { - [JsonProperty("message")] + [JsonPropertyName("message")] public string Message { get; set; } } #pragma warning restore 1591 } +