From 429252e61fcde3ac913a34a52ff49d70f6640d20 Mon Sep 17 00:00:00 2001 From: Brian Bicknell Date: Tue, 2 Sep 2025 23:44:31 -0400 Subject: [PATCH] Added API project. --- JSMR.Api/JSMR.Api.csproj | 19 ++ JSMR.Api/JSMR.Api.http | 56 ++++++ JSMR.Api/Program.cs | 103 +++++++++++ JSMR.Api/Properties/launchSettings.json | 23 +++ JSMR.Api/appsettings.Development.json | 8 + JSMR.Api/appsettings.json | 9 + JSMR.Application/Common/Language.cs | 12 +- .../ApplicationServiceCollectionExtensions.cs | 13 +- .../Tags/Queries/Search/SearchTagsHandler.cs | 20 +-- .../Queries/Search/SearchVoiceWorksHandler.cs | 37 ++-- .../Search/SearchVoiceWorksResponse.cs | 6 +- ...frastructureServiceCollectionExtensions.cs | 21 +++ .../Data/Configuration/CircleConfiguration.cs | 2 +- .../Configuration/CreatorConfiguration.cs | 2 +- .../Configuration/EnglishTagConfiguration.cs | 2 +- .../EnglishVoiceWorkConfiguration.cs | 2 +- .../Data/Configuration/TagConfiguration.cs | 2 +- .../Configuration/VoiceWorkConfiguration.cs | 2 +- .../VoiceWorkCreatorConfiguration.cs | 2 +- .../VoiceWorkSearchConfiguration.cs | 2 +- .../VoiceWorkTagConfiguration.cs | 2 +- .../MySqlVoiceWorkFullTextSearch.cs | 170 +++++++++++++++++- JSMR.sln | 8 +- 23 files changed, 474 insertions(+), 49 deletions(-) create mode 100644 JSMR.Api/JSMR.Api.csproj create mode 100644 JSMR.Api/JSMR.Api.http create mode 100644 JSMR.Api/Program.cs create mode 100644 JSMR.Api/Properties/launchSettings.json create mode 100644 JSMR.Api/appsettings.Development.json create mode 100644 JSMR.Api/appsettings.json diff --git a/JSMR.Api/JSMR.Api.csproj b/JSMR.Api/JSMR.Api.csproj new file mode 100644 index 0000000..4fb2b01 --- /dev/null +++ b/JSMR.Api/JSMR.Api.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + 35cebc06-af6a-44cf-aa71-ecdaf1edc82b + + + + + + + + + + + + diff --git a/JSMR.Api/JSMR.Api.http b/JSMR.Api/JSMR.Api.http new file mode 100644 index 0000000..3b0624b --- /dev/null +++ b/JSMR.Api/JSMR.Api.http @@ -0,0 +1,56 @@ +@host = http://localhost:5226 +@contentType = application/json + +### Search tags by name +POST {{host}}/api/tags/search +Content-Type: {{contentType}} + +{ + "options": { + "criteria": { + "name": "Heart" + }, + "pageNumber": 1, + "pageSize": 10 + } +} + +### + +### Search voice works with circle filter +POST {{host}}/api/voiceworks/search +Content-Type: {{contentType}} + +{ + "options": { + "criteria": { + "circleStatus": "Favorited", + "releaseDateStart": "2023-01-01", + "releaseDateEnd": "2024-12-31", + "locale": "English", + "supportedLanguages": ["English"] + }, + "pageNumber": 1, + "pageSize": 20, + "sortOptions": [ + { "field": "Downloads", "direction": "Descending" } + ] + } +} + +### Search voice works with keywords +POST {{host}}/api/voiceworks/search +Content-Type: {{contentType}} + +{ + "options": { + "criteria": { + "keywords": "tsundere maid" + }, + "pageNumber": 1, + "pageSize": 20, + "sortOptions": [ + { "field": "Downloads", "direction": "Descending" } + ] + } +} \ No newline at end of file diff --git a/JSMR.Api/Program.cs b/JSMR.Api/Program.cs new file mode 100644 index 0000000..fa1ea27 --- /dev/null +++ b/JSMR.Api/Program.cs @@ -0,0 +1,103 @@ +using JSMR.Application.Circles.Queries.Search; +using JSMR.Application.Creators.Queries.Search; +using JSMR.Application.DI; +using JSMR.Application.Tags.Queries.Search; +using JSMR.Application.VoiceWorks.Queries.Search; +using JSMR.Infrastructure.Data; +using JSMR.Infrastructure.DI; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.EntityFrameworkCore; +using System.Text.Json; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services + .AddMemoryCache() // TODO + .AddApplication() + .AddInfrastructure(); + +// DbContext (MySQL here; swap to Npgsql when you migrate) +var cs = builder.Configuration.GetConnectionString("AppDb") + ?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb"); + +builder.Services.AddDbContext(opt => + opt.UseMySql(cs, ServerVersion.AutoDetect(cs)) + .EnableSensitiveDataLogging(false)); + +builder.Services.AddControllers(); +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +builder.Services.Configure(options => +{ + options.SerializerOptions.PropertyNameCaseInsensitive = true; + options.SerializerOptions.Converters.Add( + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); // or null for exact names +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +// Health check +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); + +// ---- Endpoints ---- + +// Circles Search +app.MapPost("/api/circles/search", async ( + SearchCirclesRequest request, + SearchCirclesHandler handler, + CancellationToken cancallationToken) => +{ + var result = await handler.HandleAsync(request, cancallationToken); + + return Results.Ok(result); +}); + +// Voice Works Search +app.MapPost("/api/voiceworks/search", async ( + SearchVoiceWorksRequest request, + SearchVoiceWorksHandler handler, + CancellationToken cancallationToken) => +{ + var result = await handler.HandleAsync(request, cancallationToken); + + return Results.Ok(result); +}); + +// Tags Search +app.MapPost("/api/tags/search", async ( + SearchTagsRequest request, + SearchTagsHandler handler, + CancellationToken cancallationToken) => +{ + var result = await handler.HandleAsync(request, cancallationToken); + + return Results.Ok(result); +}); + +// Creators Search +app.MapPost("/api/creators/search", async ( + SearchCreatorsRequest request, + SearchCreatorsHandler handler, + CancellationToken cancallationToken) => +{ + var result = await handler.HandleAsync(request, cancallationToken); + + return Results.Ok(result); +}); + +app.Run(); diff --git a/JSMR.Api/Properties/launchSettings.json b/JSMR.Api/Properties/launchSettings.json new file mode 100644 index 0000000..63877de --- /dev/null +++ b/JSMR.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5226", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7277;http://localhost:5226", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/JSMR.Api/appsettings.Development.json b/JSMR.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/JSMR.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/JSMR.Api/appsettings.json b/JSMR.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/JSMR.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/JSMR.Application/Common/Language.cs b/JSMR.Application/Common/Language.cs index 5f2c384..b93db81 100644 --- a/JSMR.Application/Common/Language.cs +++ b/JSMR.Application/Common/Language.cs @@ -2,10 +2,10 @@ public enum Language { - Unknown, - Japanese, - English, - ChineseSimplified, - ChineseTraditional, - Korean + Unknown = -1, + Japanese = 0, + English = 1, + ChineseSimplified = 2, + ChineseTraditional = 3, + Korean = 4 } \ No newline at end of file diff --git a/JSMR.Application/DI/ApplicationServiceCollectionExtensions.cs b/JSMR.Application/DI/ApplicationServiceCollectionExtensions.cs index 5017851..f5d61f2 100644 --- a/JSMR.Application/DI/ApplicationServiceCollectionExtensions.cs +++ b/JSMR.Application/DI/ApplicationServiceCollectionExtensions.cs @@ -1,5 +1,9 @@ -using JSMR.Application.Tags.Commands.SetEnglishName; +using JSMR.Application.Circles.Queries.Search; +using JSMR.Application.Creators.Queries.Search; +using JSMR.Application.Tags.Commands.SetEnglishName; using JSMR.Application.Tags.Commands.UpdateTagStatus; +using JSMR.Application.Tags.Queries.Search; +using JSMR.Application.VoiceWorks.Queries.Search; using Microsoft.Extensions.DependencyInjection; namespace JSMR.Application.DI; @@ -9,12 +13,17 @@ public static class ApplicationServiceCollectionExtensions public static IServiceCollection AddApplication(this IServiceCollection services) { // Handlers / Use-cases - //services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); //services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + return services; } } \ No newline at end of file diff --git a/JSMR.Application/Tags/Queries/Search/SearchTagsHandler.cs b/JSMR.Application/Tags/Queries/Search/SearchTagsHandler.cs index acad2f0..9e209b2 100644 --- a/JSMR.Application/Tags/Queries/Search/SearchTagsHandler.cs +++ b/JSMR.Application/Tags/Queries/Search/SearchTagsHandler.cs @@ -5,27 +5,27 @@ using JSMR.Application.Tags.Queries.Search.Ports; namespace JSMR.Application.Tags.Queries.Search; -public sealed class SearchTagsHandler(ITagSearchProvider searchProvider, ICache cache) +public sealed class SearchTagsHandler(ITagSearchProvider searchProvider) { public async Task HandleAsync(SearchTagsRequest request, CancellationToken cancellationToken) { SearchOptions searchOptions = request.Options; - string cacheKey = $"tag:{searchOptions.GetHashCode()}"; + //string cacheKey = $"tag:{searchOptions.GetHashCode()}"; - TagSearchResults? cachedResults = await cache.GetAsync(cacheKey, cancellationToken); + //TagSearchResults? cachedResults = await cache.GetAsync(cacheKey, cancellationToken); - if (cachedResults != null) - return new SearchTagsResponse(cachedResults); + //if (cachedResults != null) + // return new SearchTagsResponse(cachedResults); SearchResult results = await searchProvider.SearchAsync(searchOptions, cancellationToken); - CacheEntryOptions cacheEntryOptions = new() - { - SlidingExpiration = TimeSpan.FromMinutes(10) - }; + //CacheEntryOptions cacheEntryOptions = new() + //{ + // SlidingExpiration = TimeSpan.FromMinutes(10) + //}; - await cache.SetAsync(cacheKey, results, cacheEntryOptions, cancellationToken); + //await cache.SetAsync(cacheKey, results, cacheEntryOptions, cancellationToken); return new SearchTagsResponse(results); } diff --git a/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksHandler.cs b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksHandler.cs index c2d9f46..7ce332a 100644 --- a/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksHandler.cs +++ b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksHandler.cs @@ -3,28 +3,29 @@ using JSMR.Application.VoiceWorks.Ports; namespace JSMR.Application.VoiceWorks.Queries.Search; -//public sealed class SearchVoiceWorksHandler(IVoiceWorkReader reader, ICache cache) -//{ -// //public async Task HandleAsync(SearchVoiceWorksRequest request, CancellationToken cancellationToken) -// //{ -// // VoiceWorkSearchOptions searchOptions = request.Options; +// TODO: Caching? +public sealed class SearchVoiceWorksHandler(IVoiceWorkSearchProvider provider) +{ + public async Task HandleAsync(SearchVoiceWorksRequest request, CancellationToken cancellationToken) + { + var searchOptions = request.Options; -// // string cacheKey = $"vw:{searchOptions.GetHashCode()}"; + //string cacheKey = $"vw:{searchOptions.GetHashCode()}"; -// // VoiceWorkSearchResults? cachedResults = await cache.GetAsync(cacheKey, cancellationToken); + //VoiceWorkSearchResults? cachedResults = await cache.GetAsync(cacheKey, cancellationToken); -// // if (cachedResults != null) -// // return new SearchVoiceWorksResponse(cachedResults); + //if (cachedResults != null) + // return new SearchVoiceWorksResponse(cachedResults); -// // VoiceWorkSearchResults results = await reader.SearchAsync(searchOptions, cancellationToken); + var results = await provider.SearchAsync(searchOptions, cancellationToken); -// // CacheEntryOptions cacheEntryOptions = new() -// // { -// // SlidingExpiration = TimeSpan.FromMinutes(10) -// // }; + //CacheEntryOptions cacheEntryOptions = new() + //{ + // SlidingExpiration = TimeSpan.FromMinutes(10) + //}; -// // await cache.SetAsync(cacheKey, results, cacheEntryOptions, cancellationToken); + //await cache.SetAsync(cacheKey, results, cacheEntryOptions, cancellationToken); -// // return new SearchVoiceWorksResponse(results); -// //} -//} \ No newline at end of file + return new SearchVoiceWorksResponse(results); + } +} \ No newline at end of file diff --git a/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksResponse.cs b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksResponse.cs index c31007a..84da921 100644 --- a/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksResponse.cs +++ b/JSMR.Application/VoiceWorks/Queries/Search/SearchVoiceWorksResponse.cs @@ -1,3 +1,5 @@ -namespace JSMR.Application.VoiceWorks.Queries.Search; +using JSMR.Application.Common.Search; -public sealed record SearchVoiceWorksResponse(VoiceWorkSearchResults Results); \ No newline at end of file +namespace JSMR.Application.VoiceWorks.Queries.Search; + +public sealed record SearchVoiceWorksResponse(SearchResult Results); \ No newline at end of file diff --git a/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs b/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs index 679d23d..855b258 100644 --- a/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs +++ b/JSMR.Infrastructure/DI/InfrastructureServiceCollectionExtensions.cs @@ -1,7 +1,17 @@ using JSMR.Application.Circles.Queries.GetCreators; using JSMR.Application.Circles.Queries.GetTags; using JSMR.Application.Circles.Queries.Search; +using JSMR.Application.Common.Caching; +using JSMR.Application.Creators.Ports; +using JSMR.Application.Creators.Queries.Search.Ports; +using JSMR.Application.Tags.Ports; +using JSMR.Application.Tags.Queries.Search.Ports; +using JSMR.Application.VoiceWorks.Queries.Search; +using JSMR.Infrastructure.Caching; using JSMR.Infrastructure.Data.Repositories.Circles; +using JSMR.Infrastructure.Data.Repositories.Creators; +using JSMR.Infrastructure.Data.Repositories.Tags; +using JSMR.Infrastructure.Data.Repositories.VoiceWorks; using Microsoft.Extensions.DependencyInjection; namespace JSMR.Infrastructure.DI; @@ -14,6 +24,17 @@ public static class InfrastructureServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + return services; } } \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/Configuration/CircleConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/CircleConfiguration.cs index 863dd31..2affd4b 100644 --- a/JSMR.Infrastructure/Data/Configuration/CircleConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/CircleConfiguration.cs @@ -8,7 +8,7 @@ public sealed class CircleConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.ToTable("circles"); + builder.ToTable("Circles"); builder.HasKey(x => x.CircleId); builder.Property(x => x.Name).IsRequired().HasMaxLength(256); diff --git a/JSMR.Infrastructure/Data/Configuration/CreatorConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/CreatorConfiguration.cs index 436b4e8..0d970c2 100644 --- a/JSMR.Infrastructure/Data/Configuration/CreatorConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/CreatorConfiguration.cs @@ -8,7 +8,7 @@ public sealed class CreatorConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.ToTable("creators"); + builder.ToTable("Creators"); builder.HasKey(x => x.CreatorId); builder.Property(x => x.Name).IsRequired().HasMaxLength(128); diff --git a/JSMR.Infrastructure/Data/Configuration/EnglishTagConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/EnglishTagConfiguration.cs index ecc6adb..50aa117 100644 --- a/JSMR.Infrastructure/Data/Configuration/EnglishTagConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/EnglishTagConfiguration.cs @@ -8,7 +8,7 @@ public sealed class EnglishTagConfiguration : IEntityTypeConfiguration builder) { - builder.ToTable("english_tags"); + builder.ToTable("EnglishTags"); builder.HasKey(x => x.EnglishTagId); builder.Property(x => x.Name).IsRequired().HasMaxLength(128); diff --git a/JSMR.Infrastructure/Data/Configuration/EnglishVoiceWorkConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/EnglishVoiceWorkConfiguration.cs index f2a9302..fb023d1 100644 --- a/JSMR.Infrastructure/Data/Configuration/EnglishVoiceWorkConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/EnglishVoiceWorkConfiguration.cs @@ -8,7 +8,7 @@ public sealed class EnglishVoiceWorkConfiguration : IEntityTypeConfiguration builder) { - builder.ToTable("english_voice_works"); + builder.ToTable("EnglishVoiceWorks"); builder.HasKey(x => x.EnglishVoiceWorkId); builder.Property(x => x.ProductName).HasMaxLength(256); diff --git a/JSMR.Infrastructure/Data/Configuration/TagConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/TagConfiguration.cs index c0f297f..31a7892 100644 --- a/JSMR.Infrastructure/Data/Configuration/TagConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/TagConfiguration.cs @@ -8,7 +8,7 @@ public sealed class TagConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.ToTable("tags"); + builder.ToTable("Tags"); builder.HasKey(x => x.TagId); builder.Property(x => x.Name).IsRequired().HasMaxLength(128); diff --git a/JSMR.Infrastructure/Data/Configuration/VoiceWorkConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/VoiceWorkConfiguration.cs index 3a550f3..4b10714 100644 --- a/JSMR.Infrastructure/Data/Configuration/VoiceWorkConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/VoiceWorkConfiguration.cs @@ -8,7 +8,7 @@ public sealed class VoiceWorkConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { - builder.ToTable("voice_works"); + builder.ToTable("VoiceWorks"); builder.HasKey(x => x.VoiceWorkId); builder.Property(x => x.ProductId) diff --git a/JSMR.Infrastructure/Data/Configuration/VoiceWorkCreatorConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/VoiceWorkCreatorConfiguration.cs index fb66009..d96e089 100644 --- a/JSMR.Infrastructure/Data/Configuration/VoiceWorkCreatorConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/VoiceWorkCreatorConfiguration.cs @@ -8,7 +8,7 @@ public sealed class VoiceWorkCreatorConfiguration : IEntityTypeConfiguration builder) { - builder.ToTable("voice_work_creators"); + builder.ToTable("VoiceWorkCreators"); builder.HasKey(x => new { x.VoiceWorkId, x.CreatorId }); builder.HasOne(x => x.VoiceWork) diff --git a/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs index 8d6c32f..d1f46c1 100644 --- a/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/VoiceWorkSearchConfiguration.cs @@ -8,7 +8,7 @@ public sealed class VoiceWorkSearchConfiguration : IEntityTypeConfiguration builder) { - builder.ToTable("voice_work_searches"); + builder.ToTable("VoiceWorkSearches"); builder.HasKey(x => x.VoiceWorkId); // also the FK builder.Property(x => x.SearchText).IsRequired(); // TEXT/LONGTEXT diff --git a/JSMR.Infrastructure/Data/Configuration/VoiceWorkTagConfiguration.cs b/JSMR.Infrastructure/Data/Configuration/VoiceWorkTagConfiguration.cs index 947acf8..19acd3f 100644 --- a/JSMR.Infrastructure/Data/Configuration/VoiceWorkTagConfiguration.cs +++ b/JSMR.Infrastructure/Data/Configuration/VoiceWorkTagConfiguration.cs @@ -8,7 +8,7 @@ public sealed class VoiceWorkTagConfiguration : IEntityTypeConfiguration builder) { - builder.ToTable("voice_work_tags"); + builder.ToTable("VoiceWorkTags"); builder.HasKey(x => new { x.VoiceWorkId, x.TagId }); builder.HasOne(x => x.VoiceWork) diff --git a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs index 3ca20fe..a923048 100644 --- a/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs +++ b/JSMR.Infrastructure/Data/Repositories/VoiceWorks/MySqlVoiceWorkFullTextSearch.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using System.Text; namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks; @@ -6,6 +7,173 @@ public class MySqlVoiceWorkFullTextSearch : IVoiceWorkFullTextSearch { public IQueryable MatchingIds(AppDbContext context, string searchText) => context.VoiceWorkSearches - .Where(v => EF.Functions.Match(v.SearchText, searchText, MySqlMatchSearchMode.Boolean) > 0) + .Where(v => EF.Functions.Match(v.SearchText, MySqlBooleanQuery.Normalize(searchText), MySqlMatchSearchMode.Boolean) > 0) .Select(v => v.VoiceWorkId); +} + +public static class MySqlBooleanQuery +{ + // Main entry + public static string Normalize(string input) + { + if (string.IsNullOrWhiteSpace(input)) return string.Empty; + + // Split into top-level tokens by spaces (not inside quotes/parentheses) + var tokens = SplitTopLevel(input.Trim(), ' '); + var parts = new List(tokens.Count); + + foreach (var raw in tokens) + { + var t = raw.Trim(); + if (t.Length == 0) continue; + + // Preserve explicit boolean operators user may already supply + if (t[0] == '-' || t[0] == '+') + { + // Token already has a sign; normalize rest + var sign = t[0]; + var body = t.Substring(1).Trim(); + parts.Add(sign + NormalizePositive(body)); + continue; + } + + // Default: required term + parts.Add("+" + NormalizePositive(t)); + } + + return string.Join(' ', parts.Where(p => p.Length > 1 || p == "+" || p == "-")).Trim(); + } + + // Normalize a positive (non-signed) token: handles ORs, phrases, grouping + private static string NormalizePositive(string token) + { + if (string.IsNullOrWhiteSpace(token)) return string.Empty; + + // If token is a quoted phrase already -> keep quotes + if (IsQuoted(token)) + { + return EnsureBalancedQuotes(token); + } + + // If token starts/ends with parentheses, leave as-is (user grouping) + if (token.StartsWith("(") && token.EndsWith(")") && token.Length > 2) + { + // Optionally normalize inside the group if you want; + // here we trust user's grouping. + return token; + } + + // If token contains OR '|' at top level, convert to (a|b|...) + if (ContainsTopLevel(token, '|')) + { + var orParts = SplitTopLevel(token, '|') + .Select(p => NormalizeOrAtom(p.Trim())) + .Where(p => p.Length > 0); + return "(" + string.Join("|", orParts) + ")"; + } + + // Plain atom -> as-is + return token; + } + + // Normalize one OR-side atom (could be phrase or bare word) + private static string NormalizeOrAtom(string atom) + { + if (string.IsNullOrWhiteSpace(atom)) return string.Empty; + + // Allow nested quotes inside OR + if (IsQuoted(atom)) return EnsureBalancedQuotes(atom); + + // If it contains whitespace, quote it to become a phrase + if (atom.Any(char.IsWhiteSpace)) + return $"\"{EscapeQuotes(atom)}\""; + + return atom; + } + + // -------------- helpers -------------- + + private static bool IsQuoted(string s) + => s.Length >= 2 && s[0] == '"' && s[^1] == '"'; + + private static string EnsureBalancedQuotes(string s) + { + // If user typed starting quote but forgot closing, close it. + if (s.Length >= 1 && s[0] == '"' && (s.Length == 1 || s[^1] != '"')) + return s + "\""; + + // If user typed closing quote but no opening, open it. + if (s.Length >= 1 && s[^1] == '"' && (s.Length == 1 || s[0] != '"')) + return "\"" + s; + + // Also escape any embedded quotes (rare) + if (IsQuoted(s)) + { + var inner = s.Substring(1, s.Length - 2); + return $"\"{EscapeQuotes(inner)}\""; + } + + return s; + } + + private static string EscapeQuotes(string s) => s.Replace("\"", "\\\""); + + private static bool ContainsTopLevel(string s, char sep) + { + int depth = 0; + bool inQuotes = false; + + foreach (var ch in s) + { + if (ch == '"' && depth == 0) inQuotes = !inQuotes; + else if (!inQuotes) + { + if (ch == '(') depth++; + else if (ch == ')' && depth > 0) depth--; + else if (ch == sep && depth == 0) return true; + } + } + return false; + } + + private static List SplitTopLevel(string s, char sep) + { + var list = new List(); + var sb = new StringBuilder(); + int depth = 0; + bool inQuotes = false; + + for (int i = 0; i < s.Length; i++) + { + var ch = s[i]; + + if (ch == '"' && depth == 0) + { + inQuotes = !inQuotes; + sb.Append(ch); + continue; + } + + if (!inQuotes) + { + if (ch == '(') { depth++; sb.Append(ch); continue; } + if (ch == ')') { depth = Math.Max(0, depth - 1); sb.Append(ch); continue; } + + if (ch == sep && depth == 0) + { + // split + var piece = sb.ToString().Trim(); + if (piece.Length > 0) list.Add(piece); + sb.Clear(); + continue; + } + } + + sb.Append(ch); + } + + var tail = sb.ToString().Trim(); + if (tail.Length > 0) list.Add(tail); + return list; + } } \ No newline at end of file diff --git a/JSMR.sln b/JSMR.sln index cd3c108..bfb2aee 100644 --- a/JSMR.sln +++ b/JSMR.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36408.4 d17.14 +VisualStudioVersion = 17.14.36408.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JSMR.Domain", "JSMR.Domain\JSMR.Domain.csproj", "{BC16F228-63B0-4EE6-9B96-19A38A31C125}" EndProject @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JSMR.Infrastructure", "JSMR EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JSMR.Tests", "JSMR.Tests\JSMR.Tests.csproj", "{9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JSMR.Api", "JSMR.Api\JSMR.Api.csproj", "{C60ECDB4-0EBF-4682-AC07-199A1051D9AE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}.Debug|Any CPU.Build.0 = Debug|Any CPU {9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}.Release|Any CPU.ActiveCfg = Release|Any CPU {9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}.Release|Any CPU.Build.0 = Release|Any CPU + {C60ECDB4-0EBF-4682-AC07-199A1051D9AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C60ECDB4-0EBF-4682-AC07-199A1051D9AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C60ECDB4-0EBF-4682-AC07-199A1051D9AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C60ECDB4-0EBF-4682-AC07-199A1051D9AE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE