From 732b3d5e62fae6f530b46ebd2ad65294605ef868 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Thu, 20 Feb 2025 09:19:16 +0300 Subject: [PATCH 001/156] feat: Web host + ASP.Net Core Identity --- src/.dockerignore | 25 ++ .../Class1.cs | 14 + ...Controllers.Web.AspNetCore.Identity.csproj | 14 + .../Commands/ListEffectiveSettingsVerb.cs | 13 +- .../Commands/PrepareDbHandler.cs | 22 +- .../NVs.Budget.Hosts.Console.csproj | 1 - .../UserCacheInitializer.cs | 20 +- src/Hosts/NVs.Budget.Hosts.Web/Dockerfile | 23 ++ .../NVs.Budget.Hosts.Web.csproj | 30 ++ src/Hosts/NVs.Budget.Hosts.Web/Program.cs | 37 +++ .../Properties/launchSettings.json | 38 +++ .../appsettings.Development.json | 8 + .../NVs.Budget.Hosts.Web/appsettings.json | 22 ++ .../AppIdentityDbContext.cs | 13 + .../AspNetCoreIdentityExtensions.cs | 25 ++ .../ClaimsUser.cs | 10 + .../DesignTimeContextFactory.cs | 14 + .../HttpContextBasedIdentityService.cs | 22 ++ ....Persistence.EF.AspNetCore.Identity.csproj | 25 ++ .../20250220061126_Initial.Designer.cs | 278 ++++++++++++++++++ .../Migrations/20250220061126_Initial.cs | 253 ++++++++++++++++ .../AppIdentityDbContextModelSnapshot.cs | 275 +++++++++++++++++ .../DbConnectionInfo.cs | 10 +- .../DesignTimeContextFactory.cs | 13 + .../IDbConnectionInfo.cs | 10 + .../IDbMigrator.cs | 5 +- ...nfrastructure.Persistence.EF.Common.csproj | 15 + .../PostgreSqlDbMigrator.cs | 5 +- .../Fixtures/DatabaseCollectionFixture.cs | 1 + ...Infrastructure.Persistence.EF.Tests.csproj | 1 - .../Context/DesignTimeContextFactory.cs | 13 - .../Context/IDbConnectionInfo.cs | 8 - .../EfCorePersistenceExtensions.cs | 5 +- ...udget.Infrastructure.Persistence.EF.csproj | 6 +- src/NVs.Budget.sln | 33 +++ src/compose.yaml | 6 + 36 files changed, 1254 insertions(+), 59 deletions(-) create mode 100644 src/.dockerignore create mode 100644 src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/Class1.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj create mode 100644 src/Hosts/NVs.Budget.Hosts.Web/Dockerfile create mode 100644 src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj create mode 100644 src/Hosts/NVs.Budget.Hosts.Web/Program.cs create mode 100644 src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web/appsettings.Development.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web/appsettings.json create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AppIdentityDbContext.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AspNetCoreIdentityExtensions.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/ClaimsUser.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/DesignTimeContextFactory.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/HttpContextBasedIdentityService.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.Designer.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/AppIdentityDbContextModelSnapshot.cs rename src/Infrastructure/{NVs.Budget.Infrastructure.Persistence.EF/Context => NVs.Budget.Infrastructure.Persistence.EF.Common}/DbConnectionInfo.cs (62%) create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DesignTimeContextFactory.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbConnectionInfo.cs rename src/Infrastructure/{NVs.Budget.Infrastructure.Persistence.EF/Context => NVs.Budget.Infrastructure.Persistence.EF.Common}/IDbMigrator.cs (60%) create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/NVs.Budget.Infrastructure.Persistence.EF.Common.csproj rename src/Infrastructure/{NVs.Budget.Infrastructure.Persistence.EF/Context => NVs.Budget.Infrastructure.Persistence.EF.Common}/PostgreSqlDbMigrator.cs (78%) delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/DesignTimeContextFactory.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/IDbConnectionInfo.cs create mode 100644 src/compose.yaml diff --git a/src/.dockerignore b/src/.dockerignore new file mode 100644 index 00000000..cd967fc3 --- /dev/null +++ b/src/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/Class1.cs b/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/Class1.cs new file mode 100644 index 00000000..0181160c --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/Class1.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace NVs.Budget.Controllers.Web.AspNetCore.Identity; + +public static class AspNetCoreIdentityExtensions +{ + public static IServiceCollection AddAspNetCoreIdentityUI(this IServiceCollection services) + { + services.AddDefaultIdentity(); + + return services; + } +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj b/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj new file mode 100644 index 00000000..2b88a021 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Console/Commands/ListEffectiveSettingsVerb.cs b/src/Hosts/NVs.Budget.Hosts.Console/Commands/ListEffectiveSettingsVerb.cs index 6fae1443..ada43491 100644 --- a/src/Hosts/NVs.Budget.Hosts.Console/Commands/ListEffectiveSettingsVerb.cs +++ b/src/Hosts/NVs.Budget.Hosts.Console/Commands/ListEffectiveSettingsVerb.cs @@ -5,6 +5,7 @@ using NVs.Budget.Controllers.Console.Contracts.Commands; using NVs.Budget.Infrastructure.IO.Console.Options; using NVs.Budget.Infrastructure.IO.Console.Output; +using NVs.Budget.Infrastructure.Persistence.EF.Common; using NVs.Budget.Infrastructure.Persistence.EF.Context; namespace NVs.Budget.Hosts.Console.Commands; @@ -16,7 +17,7 @@ internal class ListEffectiveSettingsVerbHandler( IOutputStreamProvider streams, IOptions outputOptions, IConfigurationRoot configuration, - IDbConnectionInfo dbConnectionInfo + IEnumerable dbConnectionInfos ) : IRequestHandler { public async Task Handle(ListEffectiveSettingsVerb request, CancellationToken cancellationToken) @@ -41,9 +42,13 @@ public async Task Handle(ListEffectiveSettingsVerb request, Cancellati await writer.WriteLineAsync(); await writer.WriteLineAsync("3. EF Core: Database"); - await writer.WriteLineAsync($"{nameof(dbConnectionInfo.DataSource)}: {dbConnectionInfo.DataSource}"); - await writer.WriteLineAsync($"{nameof(dbConnectionInfo.Database)}: {dbConnectionInfo.Database}"); - await writer.WriteLineAsync(); + foreach (var dbConnectionInfo in dbConnectionInfos) + { + await writer.WriteLineAsync($"{nameof(dbConnectionInfo.Context)}: {dbConnectionInfo.Context}"); + await writer.WriteLineAsync($"{nameof(dbConnectionInfo.DataSource)}: {dbConnectionInfo.DataSource}"); + await writer.WriteLineAsync($"{nameof(dbConnectionInfo.Database)}: {dbConnectionInfo.Database}"); + await writer.WriteLineAsync(); + } await writer.WriteLineAsync("4. Parsing rules"); await writer.WriteLineAsync($"CultureCode: {configuration.GetValue("CultureCode") ?? "not specified"}"); diff --git a/src/Hosts/NVs.Budget.Hosts.Console/Commands/PrepareDbHandler.cs b/src/Hosts/NVs.Budget.Hosts.Console/Commands/PrepareDbHandler.cs index 620e2ccb..2e3dd684 100644 --- a/src/Hosts/NVs.Budget.Hosts.Console/Commands/PrepareDbHandler.cs +++ b/src/Hosts/NVs.Budget.Hosts.Console/Commands/PrepareDbHandler.cs @@ -9,19 +9,23 @@ namespace NVs.Budget.Hosts.Console.Commands; [UsedImplicitly] -internal class PrepareDbHandler(IDbMigrator migrator, IResultWriter writer) : IRequestHandler +internal class PrepareDbHandler(IEnumerable migrators, IResultWriter writer) : IRequestHandler { public async Task Handle(PrepareDbVerb request, CancellationToken cancellationToken) { - try + foreach (var migrator in migrators) { - await migrator.MigrateAsync(cancellationToken); - return ExitCode.Success; - } - catch (Exception e) - { - await writer.Write(Result.Fail(new ExceptionBasedError(e)), cancellationToken); - return ExitCode.OperationError; + try + { + await migrator.MigrateAsync(cancellationToken); + } + catch (Exception e) + { + await writer.Write(Result.Fail(new ExceptionBasedError(e)), cancellationToken); + return ExitCode.OperationError; + } } + + return ExitCode.Success; } } diff --git a/src/Hosts/NVs.Budget.Hosts.Console/NVs.Budget.Hosts.Console.csproj b/src/Hosts/NVs.Budget.Hosts.Console/NVs.Budget.Hosts.Console.csproj index bdd13717..a764ef47 100644 --- a/src/Hosts/NVs.Budget.Hosts.Console/NVs.Budget.Hosts.Console.csproj +++ b/src/Hosts/NVs.Budget.Hosts.Console/NVs.Budget.Hosts.Console.csproj @@ -21,7 +21,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/src/Hosts/NVs.Budget.Hosts.Console/UserCacheInitializer.cs b/src/Hosts/NVs.Budget.Hosts.Console/UserCacheInitializer.cs index 155b8e15..426cf70c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Console/UserCacheInitializer.cs +++ b/src/Hosts/NVs.Budget.Hosts.Console/UserCacheInitializer.cs @@ -10,16 +10,16 @@ public async Task TryInitializeCache(CancellationToken ct) using var _ = logger.BeginScope("[User cache init]"); { - } - logger.LogDebug("Initializing user cache..."); - try - { - await cache.EnsureInitialized(ct); - logger.LogDebug("Cache initialized"); - } - catch (Exception e) - { - logger.LogWarning(e, "Failed to initialize cache! Most of the operations would not work properly!"); + logger.LogDebug("Initializing user cache..."); + try + { + await cache.EnsureInitialized(ct); + logger.LogDebug("Cache initialized"); + } + catch (Exception e) + { + logger.LogWarning(e, "Failed to initialize cache! Most of the operations would not work properly!"); + } } } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Dockerfile b/src/Hosts/NVs.Budget.Hosts.Web/Dockerfile new file mode 100644 index 00000000..6052fd63 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj", "Hosts/NVs.Budget.Hosts.Web/"] +RUN dotnet restore "Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj" +COPY . . +WORKDIR "/src/Hosts/NVs.Budget.Hosts.Web" +RUN dotnet build "NVs.Budget.Hosts.Web.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "NVs.Budget.Hosts.Web.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "NVs.Budget.Hosts.Web.dll"] diff --git a/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj b/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj new file mode 100644 index 00000000..4107b8aa --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + + + + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web/Program.cs new file mode 100644 index 00000000..6a707599 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web/Program.cs @@ -0,0 +1,37 @@ +using NVs.Budget.Application; +using NVs.Budget.Application.Contracts.Entities; +using NVs.Budget.Application.Contracts.Services; +using NVs.Budget.Application.UseCases; +using NVs.Budget.Controllers.Web.AspNetCore.Identity; +using NVs.Budget.Infrastructure.ExchangeRates.CBRF; +using NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; +using NVs.Budget.Infrastructure.Persistence.EF; +using NVs.Budget.Infrastructure.Persistence.EF.Common; +using NVs.Budget.Utilities.Expressions; +using Serilog; + + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddLogging(b => b.AddSerilog(dispose: true)); +builder.Services.AddEfCorePersistence( + builder.Configuration.GetConnectionString("BudgetContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!"), + ReadableExpressionsParser.Default + ) + .AddAspNetCoreIdentityPersistence(builder.Configuration.GetConnectionString("IdentityContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!")) + .AddAspNetCoreIdentityUI() + .AddScoped() + .AddScoped(p => p.GetRequiredService().CachedUser) + .AddTransient() + .AddTransient(p => p.GetRequiredService().CreateAccountant()) + .AddTransient(p => p.GetRequiredService().CreateAccountManager()) + .AddTransient(p => p.GetRequiredService().CreateReckoner()) + .AddApplicationUseCases() + .AddSingleton(new Factory().CreateProvider()); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapGet("/", () => "OK"); + +app.Run(); diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json b/src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json new file mode 100644 index 00000000..84ec27ed --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:12254", + "sslPort": 44337 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7237;http://localhost:5153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web/appsettings.Development.json b/src/Hosts/NVs.Budget.Hosts.Web/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web/appsettings.json b/src/Hosts/NVs.Budget.Hosts.Web/appsettings.json new file mode 100644 index 00000000..f2a33d28 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web/appsettings.json @@ -0,0 +1,22 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": "Error", + "WriteTo": [ + { "Name": "Console" } + ], + "Enrich": [ "FromLogContext" ], + "Destructure": [ + { "Name": "ToMaximumDepth", "Args": { "maximumDestructuringDepth": 4 } }, + { "Name": "ToMaximumStringLength", "Args": { "maximumStringLength": 100 } }, + { "Name": "ToMaximumCollectionCount", "Args": { "maximumCollectionCount": 10 } } + ] + }, + + "ConnectionStrings": { + "BudgetContext": "", + "IdentityContext": "" + }, + + "AllowedHosts": "*" +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AppIdentityDbContext.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AppIdentityDbContext.cs new file mode 100644 index 00000000..f11b181a --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AppIdentityDbContext.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; + +internal class AppIdentityDbContext(DbContextOptions options) : IdentityDbContext(options) +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.HasDefaultSchema("budget_identity"); + base.OnModelCreating(builder); + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AspNetCoreIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AspNetCoreIdentityExtensions.cs new file mode 100644 index 00000000..850188ec --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AspNetCoreIdentityExtensions.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NVs.Budget.Infrastructure.Identity.Contracts; +using NVs.Budget.Infrastructure.Persistence.EF.Common; +using NVs.Budget.Infrastructure.Persistence.EF.Context; + +namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; + +public static class AspNetCoreIdentityExtensions +{ + public static IServiceCollection AddAspNetCoreIdentityPersistence(this IServiceCollection services, string connectionString) + { + services.AddDbContext(o => o.UseNpgsql(connectionString)) + .AddIdentity() + .AddEntityFrameworkStores(); + + services.AddTransient(s => new DbConnectionInfo(s.GetRequiredService())); + services.AddTransient(s => new PostgreSqlDbMigrator(s.GetRequiredService())); + services.AddScoped(); + + return services; + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/ClaimsUser.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/ClaimsUser.cs new file mode 100644 index 00000000..5e8f04bb --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/ClaimsUser.cs @@ -0,0 +1,10 @@ +using NVs.Budget.Application.Contracts.Entities; +using NVs.Budget.Domain.Entities.Accounts; + +namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; + +internal class ClaimsUser(string id, Owner owner) : IUser +{ + public string Id { get; } = id; + public Owner AsOwner() => owner; +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/DesignTimeContextFactory.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/DesignTimeContextFactory.cs new file mode 100644 index 00000000..a844c280 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/DesignTimeContextFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; + +internal sealed class DesignTimeContextFactory : IDesignTimeDbContextFactory +{ + public AppIdentityDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder().UseNpgsql().Options; + return new AppIdentityDbContext(options); + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/HttpContextBasedIdentityService.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/HttpContextBasedIdentityService.cs new file mode 100644 index 00000000..00b46c2c --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/HttpContextBasedIdentityService.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using NVs.Budget.Application.Contracts.Entities; +using NVs.Budget.Infrastructure.Identity.Contracts; +using NVs.Budget.Infrastructure.Persistence.Contracts.Accounting; + +namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; + +internal class HttpContextBasedIdentityService(IHttpContextAccessor httpContextAccessor, IOwnersRepository owners) : IIdentityService +{ + IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + + public async Task GetCurrentUser(CancellationToken ct) + { + var principal = _httpContextAccessor.HttpContext?.User ?? throw new InvalidOperationException("User not found"); + var email = principal.FindFirstValue(ClaimTypes.Email) ?? throw new InvalidOperationException("Email not found"); + + var found = await owners.Get(o => o.Name == email, ct); + + return new ClaimsUser(email, found.Single()); + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj new file mode 100644 index 00000000..5a0685d1 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.Designer.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.Designer.cs new file mode 100644 index 00000000..e78f9837 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.Designer.cs @@ -0,0 +1,278 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.Persistence.Migrations +{ + [DbContext(typeof(AppIdentityDbContext))] + [Migration("20250220061126_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("budget_identity") + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.cs new file mode 100644 index 00000000..6632bbfa --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.cs @@ -0,0 +1,253 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.Persistence.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "budget_identity"); + + migrationBuilder.CreateTable( + name: "AspNetRoles", + schema: "budget_identity", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + schema: "budget_identity", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + schema: "budget_identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalSchema: "budget_identity", + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + schema: "budget_identity", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "budget_identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + schema: "budget_identity", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "budget_identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + schema: "budget_identity", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalSchema: "budget_identity", + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "budget_identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + schema: "budget_identity", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalSchema: "budget_identity", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + schema: "budget_identity", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + schema: "budget_identity", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + schema: "budget_identity", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + schema: "budget_identity", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + schema: "budget_identity", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + schema: "budget_identity", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + schema: "budget_identity", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims", + schema: "budget_identity"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims", + schema: "budget_identity"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins", + schema: "budget_identity"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles", + schema: "budget_identity"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens", + schema: "budget_identity"); + + migrationBuilder.DropTable( + name: "AspNetRoles", + schema: "budget_identity"); + + migrationBuilder.DropTable( + name: "AspNetUsers", + schema: "budget_identity"); + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/AppIdentityDbContextModelSnapshot.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/AppIdentityDbContextModelSnapshot.cs new file mode 100644 index 00000000..af34cec2 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/AppIdentityDbContextModelSnapshot.cs @@ -0,0 +1,275 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.Persistence.Migrations +{ + [DbContext(typeof(AppIdentityDbContext))] + partial class AppIdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("budget_identity") + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "budget_identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/DbConnectionInfo.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DbConnectionInfo.cs similarity index 62% rename from src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/DbConnectionInfo.cs rename to src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DbConnectionInfo.cs index dbd92ac8..dbaf01db 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/DbConnectionInfo.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DbConnectionInfo.cs @@ -1,10 +1,10 @@ using Microsoft.EntityFrameworkCore; -namespace NVs.Budget.Infrastructure.Persistence.EF.Context; +namespace NVs.Budget.Infrastructure.Persistence.EF.Common; -internal class DbConnectionInfo : IDbConnectionInfo +public class DbConnectionInfo : IDbConnectionInfo { - public DbConnectionInfo(BudgetContext? context) + public DbConnectionInfo(DbContext? context) { if (context is null) { @@ -15,8 +15,10 @@ public DbConnectionInfo(BudgetContext? context) var connection = context.Database.GetDbConnection(); DataSource = connection.DataSource; Database = connection.Database; + Context = context.GetType().Name; } public string? DataSource { get; } public string? Database { get; } -} \ No newline at end of file + public string? Context { get; } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DesignTimeContextFactory.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DesignTimeContextFactory.cs new file mode 100644 index 00000000..2f9a58dd --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DesignTimeContextFactory.cs @@ -0,0 +1,13 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace NVs.Budget.Infrastructure.Persistence.EF.Common; + +internal sealed class DesignTimeContextFactory : IDesignTimeDbContextFactory where T: DbContext, new() +{ + public T CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder().UseNpgsql().Options; + return (T)(Activator.CreateInstance(typeof(T), options) ?? throw new InvalidOperationException($"Could not create instance of type {typeof(T).Name}")); + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbConnectionInfo.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbConnectionInfo.cs new file mode 100644 index 00000000..75cf99cb --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbConnectionInfo.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore; + +namespace NVs.Budget.Infrastructure.Persistence.EF.Common; + +public interface IDbConnectionInfo +{ + public string? DataSource { get; } + public string? Database { get; } + public string? Context { get; } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/IDbMigrator.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbMigrator.cs similarity index 60% rename from src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/IDbMigrator.cs rename to src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbMigrator.cs index 149c19cc..c03658f5 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/IDbMigrator.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbMigrator.cs @@ -1,5 +1,8 @@ +using Microsoft.EntityFrameworkCore; + namespace NVs.Budget.Infrastructure.Persistence.EF.Context; -public interface IDbMigrator { +public interface IDbMigrator + { Task MigrateAsync(CancellationToken ct); } diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/NVs.Budget.Infrastructure.Persistence.EF.Common.csproj b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/NVs.Budget.Infrastructure.Persistence.EF.Common.csproj new file mode 100644 index 00000000..dd375b6c --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/NVs.Budget.Infrastructure.Persistence.EF.Common.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + + + + + + + + + diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/PostgreSqlDbMigrator.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/PostgreSqlDbMigrator.cs similarity index 78% rename from src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/PostgreSqlDbMigrator.cs rename to src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/PostgreSqlDbMigrator.cs index ee9b088c..14efee7e 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/PostgreSqlDbMigrator.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/PostgreSqlDbMigrator.cs @@ -1,10 +1,11 @@ using System.Data; using Microsoft.EntityFrameworkCore; using Npgsql; +using NVs.Budget.Infrastructure.Persistence.EF.Context; -namespace NVs.Budget.Infrastructure.Persistence.EF.Context; +namespace NVs.Budget.Infrastructure.Persistence.EF.Common; -internal class PostgreSqlDbMigrator(BudgetContext context) : IDbMigrator +public class PostgreSqlDbMigrator(DbContext context) : IDbMigrator { public async Task MigrateAsync(CancellationToken ct) { diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/Fixtures/DatabaseCollectionFixture.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/Fixtures/DatabaseCollectionFixture.cs index 74409dc9..8fdae42e 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/Fixtures/DatabaseCollectionFixture.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/Fixtures/DatabaseCollectionFixture.cs @@ -1,6 +1,7 @@ using AutoMapper; using AutoMapper.EquivalencyExpression; using Microsoft.EntityFrameworkCore; +using NVs.Budget.Infrastructure.Persistence.EF.Common; using NVs.Budget.Infrastructure.Persistence.EF.Context; using NVs.Budget.Infrastructure.Persistence.EF.Entities; using NVs.Budget.Utilities.Expressions; diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj index 4a2ba711..908d6b59 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj @@ -13,7 +13,6 @@ - diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/DesignTimeContextFactory.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/DesignTimeContextFactory.cs deleted file mode 100644 index de938373..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/DesignTimeContextFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace NVs.Budget.Infrastructure.Persistence.EF.Context; - -internal sealed class DesignTimeContextFactory : IDesignTimeDbContextFactory -{ - public BudgetContext CreateDbContext(string[] args) - { - var options = new DbContextOptionsBuilder().UseNpgsql().Options; - return new BudgetContext(options); - } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/IDbConnectionInfo.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/IDbConnectionInfo.cs deleted file mode 100644 index 21e0ba39..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/IDbConnectionInfo.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NVs.Budget.Infrastructure.Persistence.EF.Context; - -public interface IDbConnectionInfo -{ - public string? DataSource { get; } - - public string? Database { get; } -} \ No newline at end of file diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs index dee82757..f136e845 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using NVs.Budget.Infrastructure.IO.Console.Options; using NVs.Budget.Infrastructure.Persistence.Contracts.Accounting; +using NVs.Budget.Infrastructure.Persistence.EF.Common; using NVs.Budget.Infrastructure.Persistence.EF.Context; using NVs.Budget.Infrastructure.Persistence.EF.Entities; using NVs.Budget.Infrastructure.Persistence.EF.Repositories; @@ -24,8 +25,8 @@ public static IServiceCollection AddEfCorePersistence(this IServiceCollection se .AddTransient() .AddTransient() .AddTransient() - .AddTransient() - .AddTransient(); + .AddTransient(s => new PostgreSqlDbMigrator(s.GetRequiredService())) + .AddTransient(s => new DbConnectionInfo(s.GetRequiredService())); return services; } diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/NVs.Budget.Infrastructure.Persistence.EF.csproj b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/NVs.Budget.Infrastructure.Persistence.EF.csproj index 249bac00..466b7376 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/NVs.Budget.Infrastructure.Persistence.EF.csproj +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/NVs.Budget.Infrastructure.Persistence.EF.csproj @@ -14,13 +14,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - @@ -29,6 +26,7 @@ + diff --git a/src/NVs.Budget.sln b/src/NVs.Budget.sln index eb06beb6..df853b6a 100644 --- a/src/NVs.Budget.sln +++ b/src/NVs.Budget.sln @@ -69,6 +69,19 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.I EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Utilities.Expressions.Tests", "Utilities\NVs.Budget.Utilities.Expressions.Tests\NVs.Budget.Utilities.Expressions.Tests.csproj", "{7D3E1EE7-0B18-4C90-AFAB-DB5A6D6D433B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Hosts.Web", "Hosts\NVs.Budget.Hosts.Web\NVs.Budget.Hosts.Web.csproj", "{A129265C-EC73-4D2B-A4E8-E7DB6578D7B5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C12A475A-DE78-41F0-A270-A759D23E9403}" + ProjectSection(SolutionItems) = preProject + compose.yaml = compose.yaml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity", "Infrastructure\NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity\NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj", "{A090F5DB-DD45-4A1F-844B-EF529B0E56A6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.Persistence.EF.Common", "Infrastructure\NVs.Budget.Infrastructure.Persistence.EF.Common\NVs.Budget.Infrastructure.Persistence.EF.Common.csproj", "{6E2847D7-0B1C-4609-BB15-0AEA262B1123}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Controllers.Web.AspNetCore.Identity", "Controllers\NVs.Budget.Controllers.Web.AspNetCore.Identity\NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj", "{66DE398B-13B2-42D3-A078-CC4B91EF6080}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -102,6 +115,10 @@ Global {E7257A6C-B2C0-4B24-99EF-6E4E495FCF90} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} {2A15D8BF-AD28-4192-A9E7-B81B8612B2CF} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} {7D3E1EE7-0B18-4C90-AFAB-DB5A6D6D433B} = {52A892E9-EDF9-4297-9447-58C343C4EAF4} + {A129265C-EC73-4D2B-A4E8-E7DB6578D7B5} = {ABCBF4A8-F237-4243-9A07-27D6B361CCCB} + {A090F5DB-DD45-4A1F-844B-EF529B0E56A6} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} + {6E2847D7-0B1C-4609-BB15-0AEA262B1123} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} + {66DE398B-13B2-42D3-A078-CC4B91EF6080} = {8FBC3B88-0C56-4282-BBF3-607292E95F91} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A757A6B7-3317-493A-B9CB-DA97CCCA6A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -208,5 +225,21 @@ Global {7D3E1EE7-0B18-4C90-AFAB-DB5A6D6D433B}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D3E1EE7-0B18-4C90-AFAB-DB5A6D6D433B}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D3E1EE7-0B18-4C90-AFAB-DB5A6D6D433B}.Release|Any CPU.Build.0 = Release|Any CPU + {A129265C-EC73-4D2B-A4E8-E7DB6578D7B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A129265C-EC73-4D2B-A4E8-E7DB6578D7B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A129265C-EC73-4D2B-A4E8-E7DB6578D7B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A129265C-EC73-4D2B-A4E8-E7DB6578D7B5}.Release|Any CPU.Build.0 = Release|Any CPU + {A090F5DB-DD45-4A1F-844B-EF529B0E56A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A090F5DB-DD45-4A1F-844B-EF529B0E56A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A090F5DB-DD45-4A1F-844B-EF529B0E56A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A090F5DB-DD45-4A1F-844B-EF529B0E56A6}.Release|Any CPU.Build.0 = Release|Any CPU + {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Release|Any CPU.Build.0 = Release|Any CPU + {66DE398B-13B2-42D3-A078-CC4B91EF6080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {66DE398B-13B2-42D3-A078-CC4B91EF6080}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66DE398B-13B2-42D3-A078-CC4B91EF6080}.Release|Any CPU.ActiveCfg = Release|Any CPU + {66DE398B-13B2-42D3-A078-CC4B91EF6080}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/compose.yaml b/src/compose.yaml new file mode 100644 index 00000000..773d5979 --- /dev/null +++ b/src/compose.yaml @@ -0,0 +1,6 @@ +services: + nvs.budget.hosts.web: + image: nvs.budget.hosts.web + build: + context: . + dockerfile: Hosts/NVs.Budget.Hosts.Web/Dockerfile From 9bfdea40583d420b37faa5913534982e18cd25ce Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Thu, 20 Feb 2025 21:11:31 +0300 Subject: [PATCH 002/156] chore: removed scaffolded compose file --- src/compose.yaml | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 src/compose.yaml diff --git a/src/compose.yaml b/src/compose.yaml deleted file mode 100644 index 773d5979..00000000 --- a/src/compose.yaml +++ /dev/null @@ -1,6 +0,0 @@ -services: - nvs.budget.hosts.web: - image: nvs.budget.hosts.web - build: - context: . - dockerfile: Hosts/NVs.Budget.Hosts.Web/Dockerfile From 6264771adf8dac8f8f11e4d2aae65801b987d11b Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Thu, 20 Feb 2025 21:15:04 +0300 Subject: [PATCH 003/156] chore: removed IISExpress config --- .../Properties/launchSettings.json | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json b/src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json index 84ec27ed..04559916 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json +++ b/src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json @@ -1,13 +1,5 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:12254", - "sslPort": 44337 - } - }, "profiles": { "http": { "commandName": "Project", @@ -26,13 +18,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } From 4d38a994b2e1e792cd751387a96f45ef93202746 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Sun, 23 Feb 2025 12:25:26 +0300 Subject: [PATCH 004/156] feat(auth): OpenIddict auth --- src/.config/dotnet-tools.json | 18 ++ .../IIdentityService.cs | 2 +- .../Class1.cs | 14 - ...Controllers.Web.AspNetCore.Identity.csproj | 14 - .../NVs.Budget.Hosts.Web.csproj | 3 +- src/Hosts/NVs.Budget.Hosts.Web/Program.cs | 25 +- .../NVs.Budget.Hosts.Web/appsettings.json | 9 +- .../Mapping/UserMappingContext.cs | 18 ++ .../UserMappingContextDesignTimeDbFactory.cs | 5 + .../Mapping/UserToOwnerMapping.cs | 7 + ...ructure.Identity.OpenIddict.Yandex.csproj} | 4 +- .../Oauth2BasedIdentityService.cs | 51 ++++ .../20250223085318_Initial.Designer.cs | 44 +++ .../Migrations/20250223085318_Initial.cs | 39 +++ .../UserMappingContextModelSnapshot.cs | 41 +++ .../WebIdentityExtensions.cs | 79 +++++ .../WebUser.cs | 26 ++ .../YandexAuthConfig.cs | 3 + .../AppIdentityDbContext.cs | 13 - .../AspNetCoreIdentityExtensions.cs | 25 -- .../ClaimsUser.cs | 10 - .../DesignTimeContextFactory.cs | 14 - .../HttpContextBasedIdentityService.cs | 22 -- .../20250220061126_Initial.Designer.cs | 278 ------------------ .../Migrations/20250220061126_Initial.cs | 253 ---------------- .../AppIdentityDbContextModelSnapshot.cs | 275 ----------------- .../DesignTimeContextFactory.cs | 2 +- src/NVs.Budget.sln | 12 +- 28 files changed, 362 insertions(+), 944 deletions(-) create mode 100644 src/.config/dotnet-tools.json delete mode 100644 src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/Class1.cs delete mode 100644 src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContextDesignTimeDbFactory.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserToOwnerMapping.cs rename src/Infrastructure/{NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj => NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj} (85%) create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223085318_Initial.Designer.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223085318_Initial.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebUser.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/YandexAuthConfig.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AppIdentityDbContext.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AspNetCoreIdentityExtensions.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/ClaimsUser.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/DesignTimeContextFactory.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/HttpContextBasedIdentityService.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.Designer.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.cs delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/AppIdentityDbContextModelSnapshot.cs diff --git a/src/.config/dotnet-tools.json b/src/.config/dotnet-tools.json new file mode 100644 index 00000000..26ae7bb6 --- /dev/null +++ b/src/.config/dotnet-tools.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-aspnet-codegenerator": { + "version": "9.0.0", + "commands": [ + "dotnet-aspnet-codegenerator" + ] + }, + "dotnet-ef": { + "version": "9.0.2", + "commands": [ + "dotnet-ef" + ] + } + } +} \ No newline at end of file diff --git a/src/Application/NVs.Budget.Infrastructure.Identity.Contracts/IIdentityService.cs b/src/Application/NVs.Budget.Infrastructure.Identity.Contracts/IIdentityService.cs index b7fb7367..b38562f0 100644 --- a/src/Application/NVs.Budget.Infrastructure.Identity.Contracts/IIdentityService.cs +++ b/src/Application/NVs.Budget.Infrastructure.Identity.Contracts/IIdentityService.cs @@ -4,5 +4,5 @@ namespace NVs.Budget.Infrastructure.Identity.Contracts; public interface IIdentityService { - public Task GetCurrentUser(CancellationToken ct); + Task GetCurrentUser(CancellationToken ct); } diff --git a/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/Class1.cs b/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/Class1.cs deleted file mode 100644 index 0181160c..00000000 --- a/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/Class1.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.DependencyInjection; - -namespace NVs.Budget.Controllers.Web.AspNetCore.Identity; - -public static class AspNetCoreIdentityExtensions -{ - public static IServiceCollection AddAspNetCoreIdentityUI(this IServiceCollection services) - { - services.AddDefaultIdentity(); - - return services; - } -} diff --git a/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj b/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj deleted file mode 100644 index 2b88a021..00000000 --- a/src/Controllers/NVs.Budget.Controllers.Web.AspNetCore.Identity/NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - diff --git a/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj b/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj index 4107b8aa..c660f669 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj +++ b/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj @@ -21,9 +21,8 @@ - - + diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web/Program.cs index 6a707599..ffc7be1c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web/Program.cs +++ b/src/Hosts/NVs.Budget.Hosts.Web/Program.cs @@ -2,23 +2,26 @@ using NVs.Budget.Application.Contracts.Entities; using NVs.Budget.Application.Contracts.Services; using NVs.Budget.Application.UseCases; -using NVs.Budget.Controllers.Web.AspNetCore.Identity; using NVs.Budget.Infrastructure.ExchangeRates.CBRF; -using NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; +using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; using NVs.Budget.Infrastructure.Persistence.EF; -using NVs.Budget.Infrastructure.Persistence.EF.Common; +using NVs.Budget.Infrastructure.Persistence.EF.Context; using NVs.Budget.Utilities.Expressions; using Serilog; var builder = WebApplication.CreateBuilder(args); + builder.Services.AddLogging(b => b.AddSerilog(dispose: true)); +var identityConnectionString = builder.Configuration.GetConnectionString("IdentityContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!"); +var contentConnectionString = builder.Configuration.GetConnectionString("BudgetContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!"); + +var yandexAuthConfig = builder.Configuration.GetSection("Auth:Yandex").Get() ?? throw new InvalidOperationException("No Auth config found for Yandex provider!"); builder.Services.AddEfCorePersistence( - builder.Configuration.GetConnectionString("BudgetContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!"), + contentConnectionString, ReadableExpressionsParser.Default ) - .AddAspNetCoreIdentityPersistence(builder.Configuration.GetConnectionString("IdentityContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!")) - .AddAspNetCoreIdentityUI() + .AddYandexAuth(yandexAuthConfig, identityConnectionString) .AddScoped() .AddScoped(p => p.GetRequiredService().CachedUser) .AddTransient() @@ -28,10 +31,16 @@ .AddApplicationUseCases() .AddSingleton(new Factory().CreateProvider()); -builder.Services.AddControllers(); var app = builder.Build(); - +app.UseYandexAuth("/"); app.MapGet("/", () => "OK"); +app.MapGet("/admin/patch-db", async (IEnumerable migrators, CancellationToken ct) => +{ + foreach (var migrator in migrators) + { + await migrator.MigrateAsync(ct); + } +}); app.Run(); diff --git a/src/Hosts/NVs.Budget.Hosts.Web/appsettings.json b/src/Hosts/NVs.Budget.Hosts.Web/appsettings.json index f2a33d28..02085c58 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web/appsettings.json +++ b/src/Hosts/NVs.Budget.Hosts.Web/appsettings.json @@ -18,5 +18,12 @@ "IdentityContext": "" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + + "Auth": { + "Yandex": { + "ClientId": "YOUR_YANDEX_CLIENT_ID", + "ClientSecret": "YOUR_YANDEX_CLIENT_SECRET" + } + } } diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs new file mode 100644 index 00000000..d444bc51 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; + +internal class UserMappingContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Mappings { get; init; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasKey(mapping => new { mapping.UserId, mapping.OwnerId }); + + modelBuilder.HasDefaultSchema("user_mapping"); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContextDesignTimeDbFactory.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContextDesignTimeDbFactory.cs new file mode 100644 index 00000000..cf64eb73 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContextDesignTimeDbFactory.cs @@ -0,0 +1,5 @@ +using NVs.Budget.Infrastructure.Persistence.EF.Common; + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; + +internal class UserMappingContextDesignTimeDbFactory : DesignTimeContextFactory; \ No newline at end of file diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserToOwnerMapping.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserToOwnerMapping.cs new file mode 100644 index 00000000..600eafc8 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserToOwnerMapping.cs @@ -0,0 +1,7 @@ +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; + +public class UserToOwnerMapping +{ + public required Guid OwnerId { get; init; } + public required string UserId { get; init; } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj similarity index 85% rename from src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj rename to src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj index 5a0685d1..43d10dd5 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj @@ -7,13 +7,13 @@ - - all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs new file mode 100644 index 00000000..6293699e --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs @@ -0,0 +1,51 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using NVs.Budget.Application.Contracts.Entities; +using NVs.Budget.Infrastructure.Identity.Contracts; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; +using NVs.Budget.Infrastructure.Persistence.Contracts.Accounting; + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; + +internal class Oauth2BasedIdentityService(HttpContext httpContext, UserMappingContext mappingContext, IOwnersRepository ownersRepo) : IIdentityService +{ + public async Task GetCurrentUser(CancellationToken ct) + { + var result = await httpContext.AuthenticateAsync(); + if (result.Succeeded) + { + var userId = result.Principal.FindFirst(ClaimTypes.Email)?.Value; + if (userId == null) + { + throw new InvalidOperationException("No email found in the claims!"); + } + + var mapping = await mappingContext.Mappings.FirstOrDefaultAsync(m => m.UserId == userId, ct); + if (mapping != null) + { + + var owners = await ownersRepo.Get(o => o.Id == mapping.OwnerId, ct); + var owner = owners.FirstOrDefault(); + if (owner != null) + { + return new WebUser(userId, owner); + } + + throw new InvalidOperationException("Mapping exists, but owner was not found!"); + } + + var registrationResult = await ownersRepo.Register(new WebUser(result.Principal), ct); + if (registrationResult.IsSuccess) + { + return new WebUser(userId, registrationResult.Value); + } + + throw new InvalidOperationException("Failed to register user: " + registrationResult.Errors.Aggregate("", (s, error) => s + Environment.NewLine + error.Message)); + + } + + throw new InvalidOperationException("The user is not authenticated!"); + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223085318_Initial.Designer.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223085318_Initial.Designer.cs new file mode 100644 index 00000000..eeaa0552 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223085318_Initial.Designer.cs @@ -0,0 +1,44 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence.Migrations +{ + [DbContext(typeof(UserMappingContext))] + [Migration("20250223085318_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("user_mapping") + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping.UserToOwnerMapping", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "OwnerId"); + + b.ToTable("Mappings", "user_mapping"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223085318_Initial.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223085318_Initial.cs new file mode 100644 index 00000000..48258934 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223085318_Initial.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "user_mapping"); + + migrationBuilder.CreateTable( + name: "Mappings", + schema: "user_mapping", + columns: table => new + { + OwnerId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Mappings", x => new { x.UserId, x.OwnerId }); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Mappings", + schema: "user_mapping"); + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs new file mode 100644 index 00000000..1cf9d271 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs @@ -0,0 +1,41 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence.Migrations +{ + [DbContext(typeof(UserMappingContext))] + partial class UserMappingContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("user_mapping") + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping.UserToOwnerMapping", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "OwnerId"); + + b.ToTable("Mappings", "user_mapping"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs new file mode 100644 index 00000000..c759f2b9 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs @@ -0,0 +1,79 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NVs.Budget.Infrastructure.Identity.Contracts; +using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; +using OpenIddict.Client.WebIntegration; + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; + +public static class WebIdentityExtensions +{ + internal static class URIs + { + public static string YandexRedirectUri = "callback/login/yandex"; + } + + public static IServiceCollection AddYandexAuth(this IServiceCollection services, YandexAuthConfig config, string connectionString) + { + services.AddScoped(); + + services.AddDbContext(); + services.AddAuthentication(); + services.AddAuthorization(); + + services.AddDbContext(ops => + { + ops.UseNpgsql(connectionString); + ops.UseOpenIddict(); + }); + + services.AddOpenIddict().AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext()); + + services.AddOpenIddict() + .AddClient(opts => + { + opts.AllowAuthorizationCodeFlow() + .AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + opts.UseAspNetCore().EnableRedirectionEndpointPassthrough(); + + opts.UseWebProviders() + .AddYandex(yopts => + { + yopts.SetClientId(config.ClientId); + yopts.SetClientSecret(config.ClientSecret); + yopts.SetRedirectUri(URIs.YandexRedirectUri); + }); + }); + + return services; + } + + public static WebApplication UseYandexAuth(this WebApplication app, string authRedirectUri) + { + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapGet("challenge", () => Results.Challenge(properties: null, authenticationSchemes: [OpenIddictClientWebIntegrationConstants.Providers.Yandex])); + app.MapMethods(URIs.YandexRedirectUri, [HttpMethods.Get, HttpMethods.Post], async (HttpContext context) => + { + var result = await context.AuthenticateAsync(OpenIddictClientWebIntegrationConstants.Providers.Yandex); + return !result.Succeeded ? Results.BadRequest(result.Failure?.Message) : Results.Redirect(authRedirectUri); + }); + + app.MapGet("whoami", async (HttpContext context) => + { + var result = await context.AuthenticateAsync(); + return Results.Text(result is not { Succeeded: true } + ? "You're not logged in." + : $"You are {result.Principal.FindFirst(ClaimTypes.Name)!.Value}."); + }); + + return app; + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebUser.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebUser.cs new file mode 100644 index 00000000..10e4cd1e --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebUser.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; +using NVs.Budget.Application.Contracts.Entities; +using NVs.Budget.Domain.Entities.Accounts; + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; + +internal class WebUser : IUser +{ + private readonly Owner _owner; + public string Id { get; } + + public WebUser(string userId, Owner owner) + { + _owner = owner; + Id = userId; + } + + public WebUser(ClaimsPrincipal principal) + { + Id = principal.FindFirst(ClaimTypes.Email)?.Value ?? throw new ArgumentException("No email claim found", nameof(principal)); + var name = principal.FindFirst(ClaimTypes.Name)?.Value; + _owner = new Owner(Guid.NewGuid(), $"{name} - {Id}"); + } + + public Owner AsOwner() => _owner; +} \ No newline at end of file diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/YandexAuthConfig.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/YandexAuthConfig.cs new file mode 100644 index 00000000..91228ec7 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/YandexAuthConfig.cs @@ -0,0 +1,3 @@ +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; + +public record YandexAuthConfig( string ClientId, string ClientSecret); diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AppIdentityDbContext.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AppIdentityDbContext.cs deleted file mode 100644 index f11b181a..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AppIdentityDbContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; - -namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; - -internal class AppIdentityDbContext(DbContextOptions options) : IdentityDbContext(options) -{ - protected override void OnModelCreating(ModelBuilder builder) - { - builder.HasDefaultSchema("budget_identity"); - base.OnModelCreating(builder); - } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AspNetCoreIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AspNetCoreIdentityExtensions.cs deleted file mode 100644 index 850188ec..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/AspNetCoreIdentityExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using NVs.Budget.Infrastructure.Identity.Contracts; -using NVs.Budget.Infrastructure.Persistence.EF.Common; -using NVs.Budget.Infrastructure.Persistence.EF.Context; - -namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; - -public static class AspNetCoreIdentityExtensions -{ - public static IServiceCollection AddAspNetCoreIdentityPersistence(this IServiceCollection services, string connectionString) - { - services.AddDbContext(o => o.UseNpgsql(connectionString)) - .AddIdentity() - .AddEntityFrameworkStores(); - - services.AddTransient(s => new DbConnectionInfo(s.GetRequiredService())); - services.AddTransient(s => new PostgreSqlDbMigrator(s.GetRequiredService())); - services.AddScoped(); - - return services; - } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/ClaimsUser.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/ClaimsUser.cs deleted file mode 100644 index 5e8f04bb..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/ClaimsUser.cs +++ /dev/null @@ -1,10 +0,0 @@ -using NVs.Budget.Application.Contracts.Entities; -using NVs.Budget.Domain.Entities.Accounts; - -namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; - -internal class ClaimsUser(string id, Owner owner) : IUser -{ - public string Id { get; } = id; - public Owner AsOwner() => owner; -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/DesignTimeContextFactory.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/DesignTimeContextFactory.cs deleted file mode 100644 index a844c280..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/DesignTimeContextFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; - -internal sealed class DesignTimeContextFactory : IDesignTimeDbContextFactory -{ - public AppIdentityDbContext CreateDbContext(string[] args) - { - var options = new DbContextOptionsBuilder().UseNpgsql().Options; - return new AppIdentityDbContext(options); - } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/HttpContextBasedIdentityService.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/HttpContextBasedIdentityService.cs deleted file mode 100644 index 00b46c2c..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/HttpContextBasedIdentityService.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Security.Claims; -using Microsoft.AspNetCore.Http; -using NVs.Budget.Application.Contracts.Entities; -using NVs.Budget.Infrastructure.Identity.Contracts; -using NVs.Budget.Infrastructure.Persistence.Contracts.Accounting; - -namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; - -internal class HttpContextBasedIdentityService(IHttpContextAccessor httpContextAccessor, IOwnersRepository owners) : IIdentityService -{ - IHttpContextAccessor _httpContextAccessor = httpContextAccessor; - - public async Task GetCurrentUser(CancellationToken ct) - { - var principal = _httpContextAccessor.HttpContext?.User ?? throw new InvalidOperationException("User not found"); - var email = principal.FindFirstValue(ClaimTypes.Email) ?? throw new InvalidOperationException("Email not found"); - - var found = await owners.Get(o => o.Name == email, ct); - - return new ClaimsUser(email, found.Single()); - } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.Designer.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.Designer.cs deleted file mode 100644 index e78f9837..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.Designer.cs +++ /dev/null @@ -1,278 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.Persistence.Migrations -{ - [DbContext(typeof(AppIdentityDbContext))] - [Migration("20250220061126_Initial")] - partial class Initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("budget_identity") - .HasAnnotation("ProductVersion", "8.0.13") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("text"); - - b.Property("ClaimValue") - .HasColumnType("text"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("AccessFailedCount") - .HasColumnType("integer"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("boolean"); - - b.Property("LockoutEnabled") - .HasColumnType("boolean"); - - b.Property("LockoutEnd") - .HasColumnType("timestamp with time zone"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PasswordHash") - .HasColumnType("text"); - - b.Property("PhoneNumber") - .HasColumnType("text"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("boolean"); - - b.Property("SecurityStamp") - .HasColumnType("text"); - - b.Property("TwoFactorEnabled") - .HasColumnType("boolean"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("text"); - - b.Property("ClaimValue") - .HasColumnType("text"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("text"); - - b.Property("ProviderKey") - .HasColumnType("text"); - - b.Property("ProviderDisplayName") - .HasColumnType("text"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("text"); - - b.Property("RoleId") - .HasColumnType("text"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("text"); - - b.Property("LoginProvider") - .HasColumnType("text"); - - b.Property("Name") - .HasColumnType("text"); - - b.Property("Value") - .HasColumnType("text"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.cs deleted file mode 100644 index 6632bbfa..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/20250220061126_Initial.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.Persistence.Migrations -{ - /// - public partial class Initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "budget_identity"); - - migrationBuilder.CreateTable( - name: "AspNetRoles", - schema: "budget_identity", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetUsers", - schema: "budget_identity", - columns: table => new - { - Id = table.Column(type: "text", nullable: false), - UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "boolean", nullable: false), - PasswordHash = table.Column(type: "text", nullable: true), - SecurityStamp = table.Column(type: "text", nullable: true), - ConcurrencyStamp = table.Column(type: "text", nullable: true), - PhoneNumber = table.Column(type: "text", nullable: true), - PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), - TwoFactorEnabled = table.Column(type: "boolean", nullable: false), - LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), - LockoutEnabled = table.Column(type: "boolean", nullable: false), - AccessFailedCount = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUsers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetRoleClaims", - schema: "budget_identity", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RoleId = table.Column(type: "text", nullable: false), - ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", - column: x => x.RoleId, - principalSchema: "budget_identity", - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserClaims", - schema: "budget_identity", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - UserId = table.Column(type: "text", nullable: false), - ClaimType = table.Column(type: "text", nullable: true), - ClaimValue = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetUserClaims_AspNetUsers_UserId", - column: x => x.UserId, - principalSchema: "budget_identity", - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserLogins", - schema: "budget_identity", - columns: table => new - { - LoginProvider = table.Column(type: "text", nullable: false), - ProviderKey = table.Column(type: "text", nullable: false), - ProviderDisplayName = table.Column(type: "text", nullable: true), - UserId = table.Column(type: "text", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AspNetUserLogins_AspNetUsers_UserId", - column: x => x.UserId, - principalSchema: "budget_identity", - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserRoles", - schema: "budget_identity", - columns: table => new - { - UserId = table.Column(type: "text", nullable: false), - RoleId = table.Column(type: "text", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetRoles_RoleId", - column: x => x.RoleId, - principalSchema: "budget_identity", - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetUsers_UserId", - column: x => x.UserId, - principalSchema: "budget_identity", - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserTokens", - schema: "budget_identity", - columns: table => new - { - UserId = table.Column(type: "text", nullable: false), - LoginProvider = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Value = table.Column(type: "text", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AspNetUserTokens_AspNetUsers_UserId", - column: x => x.UserId, - principalSchema: "budget_identity", - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetRoleClaims_RoleId", - schema: "budget_identity", - table: "AspNetRoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - schema: "budget_identity", - table: "AspNetRoles", - column: "NormalizedName", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserClaims_UserId", - schema: "budget_identity", - table: "AspNetUserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserLogins_UserId", - schema: "budget_identity", - table: "AspNetUserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserRoles_RoleId", - schema: "budget_identity", - table: "AspNetUserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - schema: "budget_identity", - table: "AspNetUsers", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - schema: "budget_identity", - table: "AspNetUsers", - column: "NormalizedUserName", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AspNetRoleClaims", - schema: "budget_identity"); - - migrationBuilder.DropTable( - name: "AspNetUserClaims", - schema: "budget_identity"); - - migrationBuilder.DropTable( - name: "AspNetUserLogins", - schema: "budget_identity"); - - migrationBuilder.DropTable( - name: "AspNetUserRoles", - schema: "budget_identity"); - - migrationBuilder.DropTable( - name: "AspNetUserTokens", - schema: "budget_identity"); - - migrationBuilder.DropTable( - name: "AspNetRoles", - schema: "budget_identity"); - - migrationBuilder.DropTable( - name: "AspNetUsers", - schema: "budget_identity"); - } - } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/AppIdentityDbContextModelSnapshot.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/AppIdentityDbContextModelSnapshot.cs deleted file mode 100644 index af34cec2..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity/Persistence/Migrations/AppIdentityDbContextModelSnapshot.cs +++ /dev/null @@ -1,275 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.Persistence.Migrations -{ - [DbContext(typeof(AppIdentityDbContext))] - partial class AppIdentityDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("budget_identity") - .HasAnnotation("ProductVersion", "8.0.13") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("text"); - - b.Property("ClaimValue") - .HasColumnType("text"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => - { - b.Property("Id") - .HasColumnType("text"); - - b.Property("AccessFailedCount") - .HasColumnType("integer"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("text"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("boolean"); - - b.Property("LockoutEnabled") - .HasColumnType("boolean"); - - b.Property("LockoutEnd") - .HasColumnType("timestamp with time zone"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.Property("PasswordHash") - .HasColumnType("text"); - - b.Property("PhoneNumber") - .HasColumnType("text"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("boolean"); - - b.Property("SecurityStamp") - .HasColumnType("text"); - - b.Property("TwoFactorEnabled") - .HasColumnType("boolean"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("character varying(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("text"); - - b.Property("ClaimValue") - .HasColumnType("text"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("text"); - - b.Property("ProviderKey") - .HasColumnType("text"); - - b.Property("ProviderDisplayName") - .HasColumnType("text"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("text"); - - b.Property("RoleId") - .HasColumnType("text"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("text"); - - b.Property("LoginProvider") - .HasColumnType("text"); - - b.Property("Name") - .HasColumnType("text"); - - b.Property("Value") - .HasColumnType("text"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", "budget_identity"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DesignTimeContextFactory.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DesignTimeContextFactory.cs index 2f9a58dd..3e39f5fe 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DesignTimeContextFactory.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/DesignTimeContextFactory.cs @@ -3,7 +3,7 @@ namespace NVs.Budget.Infrastructure.Persistence.EF.Common; -internal sealed class DesignTimeContextFactory : IDesignTimeDbContextFactory where T: DbContext, new() +public class DesignTimeContextFactory : IDesignTimeDbContextFactory where T: DbContext { public T CreateDbContext(string[] args) { diff --git a/src/NVs.Budget.sln b/src/NVs.Budget.sln index df853b6a..792bfb98 100644 --- a/src/NVs.Budget.sln +++ b/src/NVs.Budget.sln @@ -72,16 +72,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Hosts.Web", "Hosts\NVs.Budget.Hosts.Web\NVs.Budget.Hosts.Web.csproj", "{A129265C-EC73-4D2B-A4E8-E7DB6578D7B5}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C12A475A-DE78-41F0-A270-A759D23E9403}" - ProjectSection(SolutionItems) = preProject - compose.yaml = compose.yaml - EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity", "Infrastructure\NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity\NVs.Budget.Infrastructure.Persistence.EF.AspNetCore.Identity.csproj", "{A090F5DB-DD45-4A1F-844B-EF529B0E56A6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex", "Infrastructure\NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex\NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj", "{A090F5DB-DD45-4A1F-844B-EF529B0E56A6}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.Persistence.EF.Common", "Infrastructure\NVs.Budget.Infrastructure.Persistence.EF.Common\NVs.Budget.Infrastructure.Persistence.EF.Common.csproj", "{6E2847D7-0B1C-4609-BB15-0AEA262B1123}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Controllers.Web.AspNetCore.Identity", "Controllers\NVs.Budget.Controllers.Web.AspNetCore.Identity\NVs.Budget.Controllers.Web.AspNetCore.Identity.csproj", "{66DE398B-13B2-42D3-A078-CC4B91EF6080}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -118,7 +113,6 @@ Global {A129265C-EC73-4D2B-A4E8-E7DB6578D7B5} = {ABCBF4A8-F237-4243-9A07-27D6B361CCCB} {A090F5DB-DD45-4A1F-844B-EF529B0E56A6} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} {6E2847D7-0B1C-4609-BB15-0AEA262B1123} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} - {66DE398B-13B2-42D3-A078-CC4B91EF6080} = {8FBC3B88-0C56-4282-BBF3-607292E95F91} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A757A6B7-3317-493A-B9CB-DA97CCCA6A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -237,9 +231,5 @@ Global {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Debug|Any CPU.Build.0 = Debug|Any CPU {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Release|Any CPU.ActiveCfg = Release|Any CPU {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Release|Any CPU.Build.0 = Release|Any CPU - {66DE398B-13B2-42D3-A078-CC4B91EF6080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {66DE398B-13B2-42D3-A078-CC4B91EF6080}.Debug|Any CPU.Build.0 = Debug|Any CPU - {66DE398B-13B2-42D3-A078-CC4B91EF6080}.Release|Any CPU.ActiveCfg = Release|Any CPU - {66DE398B-13B2-42D3-A078-CC4B91EF6080}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From a6fd78b4b8e57e0bcdd38d1ddc9a7f3f383d6f6d Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Sun, 23 Feb 2025 12:57:52 +0300 Subject: [PATCH 005/156] wip(db-migrations): fixed DI for migrators --- .../Mapping/UserMappingContext.cs | 3 +- .../Oauth2BasedIdentityService.cs | 9 +- .../UserMappingContextModelSnapshot.cs | 244 ++++++++++++++++++ .../WebIdentityExtensions.cs | 10 +- .../PostgreSqlDbMigrator.cs | 2 +- .../Fixtures/DatabaseCollectionFixture.cs | 2 +- .../Context/BudgetContext.cs | 2 +- .../EfCorePersistenceExtensions.cs | 2 +- 8 files changed, 264 insertions(+), 10 deletions(-) diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs index d444bc51..f4f2770c 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs @@ -2,7 +2,7 @@ namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; -internal class UserMappingContext(DbContextOptions options) : DbContext(options) +internal class UserMappingContext(DbContextOptions options) : DbContext(options) { public DbSet Mappings { get; init; } @@ -12,6 +12,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasKey(mapping => new { mapping.UserId, mapping.OwnerId }); modelBuilder.HasDefaultSchema("user_mapping"); + modelBuilder.UseOpenIddict(); base.OnModelCreating(modelBuilder); } diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs index 6293699e..60ea80eb 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs @@ -9,11 +9,16 @@ namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; -internal class Oauth2BasedIdentityService(HttpContext httpContext, UserMappingContext mappingContext, IOwnersRepository ownersRepo) : IIdentityService +internal class Oauth2BasedIdentityService(IHttpContextAccessor accessor, UserMappingContext mappingContext, IOwnersRepository ownersRepo) : IIdentityService { public async Task GetCurrentUser(CancellationToken ct) { - var result = await httpContext.AuthenticateAsync(); + if (accessor.HttpContext is null) + { + throw new InvalidOperationException("HttpContext is null!"); + } + + var result = await accessor.HttpContext.AuthenticateAsync(); if (result.Succeeded) { var userId = result.Principal.FindFirst(ClaimTypes.Email)?.Value; diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs index 1cf9d271..1c6f0d17 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs @@ -35,6 +35,250 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Mappings", "user_mapping"); }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs index c759f2b9..3bbc9272 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs @@ -6,30 +6,34 @@ using Microsoft.Extensions.DependencyInjection; using NVs.Budget.Infrastructure.Identity.Contracts; using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; +using NVs.Budget.Infrastructure.Persistence.EF.Common; +using NVs.Budget.Infrastructure.Persistence.EF.Context; using OpenIddict.Client.WebIntegration; namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; public static class WebIdentityExtensions { - internal static class URIs + private static class URIs { - public static string YandexRedirectUri = "callback/login/yandex"; + public static readonly string YandexRedirectUri = "callback/login/yandex"; } public static IServiceCollection AddYandexAuth(this IServiceCollection services, YandexAuthConfig config, string connectionString) { services.AddScoped(); - services.AddDbContext(); + services.AddDbContext(ops => ops.UseNpgsql(connectionString)); services.AddAuthentication(); services.AddAuthorization(); + services.AddHttpContextAccessor(); services.AddDbContext(ops => { ops.UseNpgsql(connectionString); ops.UseOpenIddict(); }); + services.AddTransient>(); services.AddOpenIddict().AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext()); diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/PostgreSqlDbMigrator.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/PostgreSqlDbMigrator.cs index 14efee7e..28aa2a15 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/PostgreSqlDbMigrator.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Common/PostgreSqlDbMigrator.cs @@ -5,7 +5,7 @@ namespace NVs.Budget.Infrastructure.Persistence.EF.Common; -public class PostgreSqlDbMigrator(DbContext context) : IDbMigrator +public class PostgreSqlDbMigrator(T context) : IDbMigrator where T : DbContext { public async Task MigrateAsync(CancellationToken ct) { diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/Fixtures/DatabaseCollectionFixture.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/Fixtures/DatabaseCollectionFixture.cs index 8fdae42e..3445576e 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/Fixtures/DatabaseCollectionFixture.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF.Tests/Fixtures/DatabaseCollectionFixture.cs @@ -28,7 +28,7 @@ public async Task InitializeAsync() { await _postgreSqlContainer.StartAsync(); var context = GetDbBudgetContext(); - await new PostgreSqlDbMigrator(context).MigrateAsync(CancellationToken.None); + await new PostgreSqlDbMigrator(context).MigrateAsync(CancellationToken.None); var owners = Mapper.Map>(TestData.Owners).ToList().ToDictionary(o => o.Id); var budgets = Mapper.Map>(TestData.Budgets).ToList(); diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/BudgetContext.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/BudgetContext.cs index 6d5db7f3..7bbb55da 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/BudgetContext.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/Context/BudgetContext.cs @@ -7,7 +7,7 @@ namespace NVs.Budget.Infrastructure.Persistence.EF.Context; -internal class BudgetContext(DbContextOptions options) : DbContext(options) +internal class BudgetContext(DbContextOptions options) : DbContext(options) { public DbSet Owners { get; init; } = null!; diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs index f136e845..8d3a7faf 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs @@ -25,7 +25,7 @@ public static IServiceCollection AddEfCorePersistence(this IServiceCollection se .AddTransient() .AddTransient() .AddTransient() - .AddTransient(s => new PostgreSqlDbMigrator(s.GetRequiredService())) + .AddTransient>() .AddTransient(s => new DbConnectionInfo(s.GetRequiredService())); return services; From 6ff3e822a4b082c4296e9788ba33029813f177bc Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Mon, 24 Feb 2025 08:50:38 +0300 Subject: [PATCH 006/156] fix(auth): fixed DI + enabled cookie auth --- .../20250223095143_OpenIddict.Designer.cs | 288 ++++++++++++++++++ .../Migrations/20250223095143_OpenIddict.cs | 183 +++++++++++ .../WebIdentityExtensions.cs | 43 ++- 3 files changed, 505 insertions(+), 9 deletions(-) create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223095143_OpenIddict.Designer.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223095143_OpenIddict.cs diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223095143_OpenIddict.Designer.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223095143_OpenIddict.Designer.cs new file mode 100644 index 00000000..f6229bb8 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223095143_OpenIddict.Designer.cs @@ -0,0 +1,288 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence.Migrations +{ + [DbContext(typeof(UserMappingContext))] + [Migration("20250223095143_OpenIddict")] + partial class OpenIddict + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("user_mapping") + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping.UserToOwnerMapping", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "OwnerId"); + + b.ToTable("Mappings", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223095143_OpenIddict.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223095143_OpenIddict.cs new file mode 100644 index 00000000..b7798e2a --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250223095143_OpenIddict.cs @@ -0,0 +1,183 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence.Migrations +{ + /// + public partial class OpenIddict : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OpenIddictApplications", + schema: "user_mapping", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ClientId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientSecret = table.Column(type: "text", nullable: true), + ClientType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConsentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + JsonWebKeySet = table.Column(type: "text", nullable: true), + Permissions = table.Column(type: "text", nullable: true), + PostLogoutRedirectUris = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedirectUris = table.Column(type: "text", nullable: true), + Requirements = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictScopes", + schema: "user_mapping", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Description = table.Column(type: "text", nullable: true), + Descriptions = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Properties = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictAuthorizations", + schema: "user_mapping", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + Properties = table.Column(type: "text", nullable: true), + Scopes = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~", + column: x => x.ApplicationId, + principalSchema: "user_mapping", + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictTokens", + schema: "user_mapping", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + AuthorizationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpirationDate = table.Column(type: "timestamp with time zone", nullable: true), + Payload = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedemptionDate = table.Column(type: "timestamp with time zone", nullable: true), + ReferenceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalSchema: "user_mapping", + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalSchema: "user_mapping", + principalTable: "OpenIddictAuthorizations", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictApplications_ClientId", + schema: "user_mapping", + table: "OpenIddictApplications", + column: "ClientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + schema: "user_mapping", + table: "OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictScopes_Name", + schema: "user_mapping", + table: "OpenIddictScopes", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", + schema: "user_mapping", + table: "OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_AuthorizationId", + schema: "user_mapping", + table: "OpenIddictTokens", + column: "AuthorizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ReferenceId", + schema: "user_mapping", + table: "OpenIddictTokens", + column: "ReferenceId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OpenIddictScopes", + schema: "user_mapping"); + + migrationBuilder.DropTable( + name: "OpenIddictTokens", + schema: "user_mapping"); + + migrationBuilder.DropTable( + name: "OpenIddictAuthorizations", + schema: "user_mapping"); + + migrationBuilder.DropTable( + name: "OpenIddictApplications", + schema: "user_mapping"); + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs index 3bbc9272..c43090e9 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs @@ -1,5 +1,7 @@ using System.Security.Claims; +using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; @@ -8,6 +10,8 @@ using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; using NVs.Budget.Infrastructure.Persistence.EF.Common; using NVs.Budget.Infrastructure.Persistence.EF.Context; +using OpenIddict.Abstractions; +using OpenIddict.Client.AspNetCore; using OpenIddict.Client.WebIntegration; namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; @@ -23,16 +27,12 @@ public static IServiceCollection AddYandexAuth(this IServiceCollection services, { services.AddScoped(); - services.AddDbContext(ops => ops.UseNpgsql(connectionString)); - services.AddAuthentication(); - services.AddAuthorization(); - services.AddHttpContextAccessor(); - services.AddDbContext(ops => { ops.UseNpgsql(connectionString); ops.UseOpenIddict(); }); + services.AddTransient>(); services.AddOpenIddict().AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext()); @@ -44,7 +44,10 @@ public static IServiceCollection AddYandexAuth(this IServiceCollection services, .AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); - opts.UseAspNetCore().EnableRedirectionEndpointPassthrough(); + opts.UseAspNetCore() + .EnableRedirectionEndpointPassthrough(); + + opts.UseSystemNetHttp(); opts.UseWebProviders() .AddYandex(yopts => @@ -55,6 +58,14 @@ public static IServiceCollection AddYandexAuth(this IServiceCollection services, }); }); + services.AddAuthorization(); + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => + { + o.ForwardChallenge = OpenIddictClientAspNetCoreDefaults.AuthenticationScheme; + }); + + services.AddHttpContextAccessor(); + return services; } @@ -63,11 +74,25 @@ public static WebApplication UseYandexAuth(this WebApplication app, string authR app.UseAuthentication(); app.UseAuthorization(); - app.MapGet("challenge", () => Results.Challenge(properties: null, authenticationSchemes: [OpenIddictClientWebIntegrationConstants.Providers.Yandex])); + app.MapGet("challenge", () => + { + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictClientAspNetCoreConstants.Properties.ProviderName] = OpenIddictClientWebIntegrationConstants.Providers.Yandex + }); + + return Results.Challenge(properties, authenticationSchemes: [OpenIddictClientAspNetCoreDefaults.AuthenticationScheme]); + }); app.MapMethods(URIs.YandexRedirectUri, [HttpMethods.Get, HttpMethods.Post], async (HttpContext context) => { - var result = await context.AuthenticateAsync(OpenIddictClientWebIntegrationConstants.Providers.Yandex); - return !result.Succeeded ? Results.BadRequest(result.Failure?.Message) : Results.Redirect(authRedirectUri); + var result = await context.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + if (result.Succeeded) + { + await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, result.Principal); + return Results.Redirect(authRedirectUri); + } + + return Results.BadRequest(result.Failure?.Message); }); app.MapGet("whoami", async (HttpContext context) => From e703ebfdc20afacdd1f376c7ad65fea427d79515 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Mon, 24 Feb 2025 09:15:01 +0300 Subject: [PATCH 007/156] feat: web controllers assembly --- .../Controllers/OwnersController.cs | 16 ++++++++++++++ .../NVs.Budget.Controllers.Web.csproj | 22 +++++++++++++++++++ .../WebControllersExtensions.cs | 22 +++++++++++++++++++ .../NVs.Budget.Hosts.Web.csproj | 1 + src/Hosts/NVs.Budget.Hosts.Web/Program.cs | 10 +++++++-- src/NVs.Budget.sln | 7 ++++++ 6 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/OwnersController.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OwnersController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OwnersController.cs new file mode 100644 index 00000000..f8fca72b --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OwnersController.cs @@ -0,0 +1,16 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NVs.Budget.Domain.Entities.Accounts; +using NVs.Budget.Infrastructure.Persistence.Contracts.Accounting; + +namespace NVs.Budget.Controllers.Web.Controllers; + +[Authorize] +[ApiVersion("0.1")] +[Route("api/{version:apiVersion}/[controller]")] +public class OwnersController(IOwnersRepository owners) : Controller +{ + [HttpGet] + public async Task> Get(CancellationToken ct) => await owners.Get(o => true, ct); +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj b/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj new file mode 100644 index 00000000..4561275c --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs new file mode 100644 index 00000000..a6efadc8 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; + +namespace NVs.Budget.Controllers.Web; + +public static class WebControllersExtensions +{ + public static IServiceCollection AddWebControllers(this IServiceCollection services) + { + var assembly = typeof(WebControllersExtensions).Assembly; + var part = new AssemblyPart(assembly); + services + .AddControllersWithViews() + .ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + + services.AddApiVersioning(); + + return services; + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj b/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj index c660f669..9c3f0e1f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj +++ b/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web/Program.cs index ffc7be1c..973d9e5d 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web/Program.cs +++ b/src/Hosts/NVs.Budget.Hosts.Web/Program.cs @@ -2,6 +2,7 @@ using NVs.Budget.Application.Contracts.Entities; using NVs.Budget.Application.Contracts.Services; using NVs.Budget.Application.UseCases; +using NVs.Budget.Controllers.Web; using NVs.Budget.Infrastructure.ExchangeRates.CBRF; using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; using NVs.Budget.Infrastructure.Persistence.EF; @@ -17,7 +18,8 @@ var contentConnectionString = builder.Configuration.GetConnectionString("BudgetContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!"); var yandexAuthConfig = builder.Configuration.GetSection("Auth:Yandex").Get() ?? throw new InvalidOperationException("No Auth config found for Yandex provider!"); -builder.Services.AddEfCorePersistence( +builder.Services + .AddEfCorePersistence( contentConnectionString, ReadableExpressionsParser.Default ) @@ -29,11 +31,13 @@ .AddTransient(p => p.GetRequiredService().CreateAccountManager()) .AddTransient(p => p.GetRequiredService().CreateReckoner()) .AddApplicationUseCases() - .AddSingleton(new Factory().CreateProvider()); + .AddSingleton(new Factory().CreateProvider()) + .AddWebControllers(); var app = builder.Build(); app.UseYandexAuth("/"); + app.MapGet("/", () => "OK"); app.MapGet("/admin/patch-db", async (IEnumerable migrators, CancellationToken ct) => { @@ -43,4 +47,6 @@ } }); +app.MapControllers(); + app.Run(); diff --git a/src/NVs.Budget.sln b/src/NVs.Budget.sln index 792bfb98..03268459 100644 --- a/src/NVs.Budget.sln +++ b/src/NVs.Budget.sln @@ -77,6 +77,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.I EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.Persistence.EF.Common", "Infrastructure\NVs.Budget.Infrastructure.Persistence.EF.Common\NVs.Budget.Infrastructure.Persistence.EF.Common.csproj", "{6E2847D7-0B1C-4609-BB15-0AEA262B1123}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Controllers.Web", "Controllers\NVs.Budget.Controllers.Web\NVs.Budget.Controllers.Web.csproj", "{EB175F71-CAA9-496A-839A-6597EC28CE7D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -113,6 +115,7 @@ Global {A129265C-EC73-4D2B-A4E8-E7DB6578D7B5} = {ABCBF4A8-F237-4243-9A07-27D6B361CCCB} {A090F5DB-DD45-4A1F-844B-EF529B0E56A6} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} {6E2847D7-0B1C-4609-BB15-0AEA262B1123} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} + {EB175F71-CAA9-496A-839A-6597EC28CE7D} = {8FBC3B88-0C56-4282-BBF3-607292E95F91} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A757A6B7-3317-493A-B9CB-DA97CCCA6A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -231,5 +234,9 @@ Global {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Debug|Any CPU.Build.0 = Debug|Any CPU {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Release|Any CPU.ActiveCfg = Release|Any CPU {6E2847D7-0B1C-4609-BB15-0AEA262B1123}.Release|Any CPU.Build.0 = Release|Any CPU + {EB175F71-CAA9-496A-839A-6597EC28CE7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB175F71-CAA9-496A-839A-6597EC28CE7D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB175F71-CAA9-496A-839A-6597EC28CE7D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB175F71-CAA9-496A-839A-6597EC28CE7D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From ba3d125d286a2c23fc21a943e6db190a12d79611 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Mon, 24 Feb 2025 21:40:41 +0300 Subject: [PATCH 008/156] chore: UserCache initialization --- .../Mapping/UserMappingContext.cs | 5 - .../Mapping/UserToOwnerMapping.cs | 7 - ...tructure.Identity.OpenIddict.Yandex.csproj | 1 + .../Oauth2BasedIdentityService.cs | 27 +- .../20250224183713_RemoveMappings.Designer.cs | 275 ++++++++++++++++++ .../20250224183713_RemoveMappings.cs | 36 +++ .../UserMappingContextModelSnapshot.cs | 13 - .../UserCacheInitializationMiddleware.cs | 19 ++ .../WebIdentityExtensions.cs | 9 +- 9 files changed, 344 insertions(+), 48 deletions(-) delete mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserToOwnerMapping.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250224183713_RemoveMappings.Designer.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250224183713_RemoveMappings.cs create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/UserCacheInitializationMiddleware.cs diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs index f4f2770c..3a37822e 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserMappingContext.cs @@ -4,13 +4,8 @@ namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; internal class UserMappingContext(DbContextOptions options) : DbContext(options) { - public DbSet Mappings { get; init; } - protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity() - .HasKey(mapping => new { mapping.UserId, mapping.OwnerId }); - modelBuilder.HasDefaultSchema("user_mapping"); modelBuilder.UseOpenIddict(); diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserToOwnerMapping.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserToOwnerMapping.cs deleted file mode 100644 index 600eafc8..00000000 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Mapping/UserToOwnerMapping.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; - -public class UserToOwnerMapping -{ - public required Guid OwnerId { get; init; } - public required string UserId { get; init; } -} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj index 43d10dd5..3f09be22 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs index 60ea80eb..b87f7dba 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs @@ -4,12 +4,11 @@ using NVs.Budget.Infrastructure.Identity.Contracts; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; -using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; using NVs.Budget.Infrastructure.Persistence.Contracts.Accounting; namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; -internal class Oauth2BasedIdentityService(IHttpContextAccessor accessor, UserMappingContext mappingContext, IOwnersRepository ownersRepo) : IIdentityService +internal class Oauth2BasedIdentityService(IHttpContextAccessor accessor, IOwnersRepository ownersRepo) : IIdentityService { public async Task GetCurrentUser(CancellationToken ct) { @@ -21,34 +20,20 @@ public async Task GetCurrentUser(CancellationToken ct) var result = await accessor.HttpContext.AuthenticateAsync(); if (result.Succeeded) { - var userId = result.Principal.FindFirst(ClaimTypes.Email)?.Value; - if (userId == null) - { - throw new InvalidOperationException("No email found in the claims!"); - } + var webUser = new WebUser(result.Principal); - var mapping = await mappingContext.Mappings.FirstOrDefaultAsync(m => m.UserId == userId, ct); - if (mapping != null) + var owner = await ownersRepo.Get(webUser, ct); + if (owner is not null) { - - var owners = await ownersRepo.Get(o => o.Id == mapping.OwnerId, ct); - var owner = owners.FirstOrDefault(); - if (owner != null) - { - return new WebUser(userId, owner); - } - - throw new InvalidOperationException("Mapping exists, but owner was not found!"); + return new WebUser(webUser.Id, owner); } - var registrationResult = await ownersRepo.Register(new WebUser(result.Principal), ct); if (registrationResult.IsSuccess) { - return new WebUser(userId, registrationResult.Value); + return new WebUser(webUser.Id, registrationResult.Value); } throw new InvalidOperationException("Failed to register user: " + registrationResult.Errors.Aggregate("", (s, error) => s + Environment.NewLine + error.Message)); - } throw new InvalidOperationException("The user is not authenticated!"); diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250224183713_RemoveMappings.Designer.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250224183713_RemoveMappings.Designer.cs new file mode 100644 index 00000000..4305ae0b --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250224183713_RemoveMappings.Designer.cs @@ -0,0 +1,275 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence.Migrations +{ + [DbContext(typeof(UserMappingContext))] + [Migration("20250224183713_RemoveMappings")] + partial class RemoveMappings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("user_mapping") + .HasAnnotation("ProductVersion", "8.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", "user_mapping"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250224183713_RemoveMappings.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250224183713_RemoveMappings.cs new file mode 100644 index 00000000..72c1dfb8 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/20250224183713_RemoveMappings.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence.Migrations +{ + /// + public partial class RemoveMappings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Mappings", + schema: "user_mapping"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Mappings", + schema: "user_mapping", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + OwnerId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Mappings", x => new { x.UserId, x.OwnerId }); + }); + } + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs index 1c6f0d17..6aa9e5d6 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/Migrations/UserMappingContextModelSnapshot.cs @@ -23,19 +23,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping.UserToOwnerMapping", b => - { - b.Property("UserId") - .HasColumnType("text"); - - b.Property("OwnerId") - .HasColumnType("uuid"); - - b.HasKey("UserId", "OwnerId"); - - b.ToTable("Mappings", "user_mapping"); - }); - modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => { b.Property("Id") diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/UserCacheInitializationMiddleware.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/UserCacheInitializationMiddleware.cs new file mode 100644 index 00000000..9317f851 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Persistence/UserCacheInitializationMiddleware.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using NVs.Budget.Application; + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence; + +internal class UserCacheInitializationMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, UserCache userCache) + { + var authResult = await context.AuthenticateAsync(); + if (authResult.Succeeded) + { + await userCache.EnsureInitialized(context.RequestAborted); + } + + await next(context); + } +} diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs index c43090e9..cc5d4486 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs @@ -6,8 +6,10 @@ using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using NVs.Budget.Application; using NVs.Budget.Infrastructure.Identity.Contracts; using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Mapping; +using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence; using NVs.Budget.Infrastructure.Persistence.EF.Common; using NVs.Budget.Infrastructure.Persistence.EF.Context; using OpenIddict.Abstractions; @@ -66,6 +68,7 @@ public static IServiceCollection AddYandexAuth(this IServiceCollection services, services.AddHttpContextAccessor(); + return services; } @@ -74,6 +77,8 @@ public static WebApplication UseYandexAuth(this WebApplication app, string authR app.UseAuthentication(); app.UseAuthorization(); + app.UseMiddleware(); + app.MapGet("challenge", () => { var properties = new AuthenticationProperties(new Dictionary @@ -95,12 +100,12 @@ public static WebApplication UseYandexAuth(this WebApplication app, string authR return Results.BadRequest(result.Failure?.Message); }); - app.MapGet("whoami", async (HttpContext context) => + app.MapGet("whoami", async (HttpContext context, UserCache cache) => { var result = await context.AuthenticateAsync(); return Results.Text(result is not { Succeeded: true } ? "You're not logged in." - : $"You are {result.Principal.FindFirst(ClaimTypes.Name)!.Value}."); + : $"You are {result.Principal.FindFirst(ClaimTypes.Name)!.Value}. Associated owner id: {cache.CachedUser.AsOwner().Id}"); }); return app; From 57551530b9a553fad418b1c353588197aa33e6a7 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Sat, 1 Mar 2025 11:05:23 +0300 Subject: [PATCH 009/156] chore: updated auth api --- .../WebIdentityExtensions.cs | 16 ++++++++++------ .../WhoamiResponse.cs | 8 ++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WhoamiResponse.cs diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs index cc5d4486..eec28dc1 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs @@ -15,6 +15,7 @@ using OpenIddict.Abstractions; using OpenIddict.Client.AspNetCore; using OpenIddict.Client.WebIntegration; +using OpenIddict.Server; namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; @@ -22,7 +23,9 @@ public static class WebIdentityExtensions { private static class URIs { - public static readonly string YandexRedirectUri = "callback/login/yandex"; + public static readonly string YandexRedirectUri = "auth/callback/login/yandex"; + public static readonly string ChallengeUrl = "auth/challenge"; + public static readonly string WhoamiUrl = "auth/whoami"; } public static IServiceCollection AddYandexAuth(this IServiceCollection services, YandexAuthConfig config, string connectionString) @@ -79,7 +82,7 @@ public static WebApplication UseYandexAuth(this WebApplication app, string authR app.UseMiddleware(); - app.MapGet("challenge", () => + app.MapGet(URIs.ChallengeUrl, () => { var properties = new AuthenticationProperties(new Dictionary { @@ -88,6 +91,7 @@ public static WebApplication UseYandexAuth(this WebApplication app, string authR return Results.Challenge(properties, authenticationSchemes: [OpenIddictClientAspNetCoreDefaults.AuthenticationScheme]); }); + app.MapMethods(URIs.YandexRedirectUri, [HttpMethods.Get, HttpMethods.Post], async (HttpContext context) => { var result = await context.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); @@ -100,12 +104,12 @@ public static WebApplication UseYandexAuth(this WebApplication app, string authR return Results.BadRequest(result.Failure?.Message); }); - app.MapGet("whoami", async (HttpContext context, UserCache cache) => + app.MapGet(URIs.WhoamiUrl, async (HttpContext context, UserCache cache) => { var result = await context.AuthenticateAsync(); - return Results.Text(result is not { Succeeded: true } - ? "You're not logged in." - : $"You are {result.Principal.FindFirst(ClaimTypes.Name)!.Value}. Associated owner id: {cache.CachedUser.AsOwner().Id}"); + return result.Succeeded + ? Results.Ok(new WhoamiResponse(true, cache.CachedUser)) + : Results.Ok(new WhoamiResponse(false, null)); }); return app; diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WhoamiResponse.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WhoamiResponse.cs new file mode 100644 index 00000000..32420fd5 --- /dev/null +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WhoamiResponse.cs @@ -0,0 +1,8 @@ +using NVs.Budget.Application.Contracts.Entities; + +namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; + +internal record WhoamiResponse( + bool IsAuthenticated, + IUser? User +); From 7730213dc518ed366abaea8e3786d7bf03bce468 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Sat, 1 Mar 2025 11:05:35 +0300 Subject: [PATCH 010/156] feat: BudgetController --- .../Controllers/BudgetController.cs | 35 +++++++++++++++++++ .../NVs.Budget.Controllers.Web.csproj | 1 + 2 files changed, 36 insertions(+) create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs new file mode 100644 index 00000000..00aece12 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -0,0 +1,35 @@ +using Asp.Versioning; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Application.Contracts.Services; +using NVs.Budget.Application.Contracts.UseCases.Accounts; + +namespace NVs.Budget.Controllers.Web.Controllers; + +[Authorize] +[ApiVersion("0.1")] +[Route("api/{version:apiVersion}/[controller]")] +public class BudgetController(IBudgetManager manager ) : Controller +{ + [HttpGet] + public Task> GetBudgets(CancellationToken ct) + { + return manager.GetOwnedBudgets(ct); + } + + [HttpGet("{id:guid}")] + public async Task GetBudget(Guid id, CancellationToken ct) + { + var budgets = await manager.GetOwnedBudgets(ct); + return budgets.FirstOrDefault(b => b.Id == id); + } + + [HttpPost] + public async Task CreateBudget([FromBody] string name, CancellationToken ct) + { + var result = await manager.Register(new(name), ct); + return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Errors); + } +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj b/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj index 4561275c..0bb190b0 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj +++ b/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj @@ -8,6 +8,7 @@ + From 347cb5556c30a300d1b2e4a4b6e6e73bef938481 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 1 Mar 2025 12:35:35 +0300 Subject: [PATCH 011/156] feat(auth): added explicit login/logout uris --- .../WebIdentityExtensions.cs | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs index eec28dc1..c06fb92b 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs @@ -1,6 +1,4 @@ -using System.Security.Claims; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -12,20 +10,21 @@ using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex.Persistence; using NVs.Budget.Infrastructure.Persistence.EF.Common; using NVs.Budget.Infrastructure.Persistence.EF.Context; -using OpenIddict.Abstractions; using OpenIddict.Client.AspNetCore; using OpenIddict.Client.WebIntegration; -using OpenIddict.Server; namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; public static class WebIdentityExtensions { + // ReSharper disable once InconsistentNaming private static class URIs { - public static readonly string YandexRedirectUri = "auth/callback/login/yandex"; - public static readonly string ChallengeUrl = "auth/challenge"; - public static readonly string WhoamiUrl = "auth/whoami"; + public static readonly string YandexRedirectUri = "/auth/callback/login/yandex"; + public static readonly string ChallengeUrl = "/auth/challenge"; + public static readonly string WhoamiUrl = "/auth/whoami"; + public static readonly string LogoutUri = "/auth/logout"; + public static readonly string LoginUrl = "/auth/login"; } public static IServiceCollection AddYandexAuth(this IServiceCollection services, YandexAuthConfig config, string connectionString) @@ -75,6 +74,7 @@ public static IServiceCollection AddYandexAuth(this IServiceCollection services, return services; } + // ReSharper disable once UnusedMethodReturnValue.Global public static WebApplication UseYandexAuth(this WebApplication app, string authRedirectUri) { app.UseAuthentication(); @@ -112,6 +112,14 @@ public static WebApplication UseYandexAuth(this WebApplication app, string authR : Results.Ok(new WhoamiResponse(false, null)); }); + app.MapGet(URIs.LogoutUri, async (HttpContext context) => + { + await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Results.Redirect(authRedirectUri); + }); + + app.MapGet(URIs.LoginUrl, (HttpContext _) => Results.Redirect(URIs.ChallengeUrl)); + return app; } } From 611734ebbd2e7552f1182d6289a7fd3915c647b7 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 1 Mar 2025 12:38:29 +0300 Subject: [PATCH 012/156] chore: split web hosts to server and client --- .../NVs.Budget.Hosts.Web.Client/Dockerfile | 23 +++++++++++++++++++ .../NVs.Budget.Hosts.Web.Client.csproj | 21 +++++++++++++++++ .../NVs.Budget.Hosts.Web.Client/Program.cs | 5 ++++ .../Properties/launchSettings.json | 23 +++++++++++++++++++ .../appsettings.Development.json | 0 .../appsettings.json | 9 ++++++++ .../Dockerfile | 0 .../NVs.Budget.Hosts.Web.Server.csproj} | 0 .../Program.cs | 0 .../Properties/launchSettings.json | 0 .../appsettings.json | 0 src/NVs.Budget.sln | 9 +++++++- 12 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/Dockerfile create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/Program.cs create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/Properties/launchSettings.json rename src/Hosts/{NVs.Budget.Hosts.Web => NVs.Budget.Hosts.Web.Client}/appsettings.Development.json (100%) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/appsettings.json rename src/Hosts/{NVs.Budget.Hosts.Web => NVs.Budget.Hosts.Web.Server}/Dockerfile (100%) rename src/Hosts/{NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj => NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj} (100%) rename src/Hosts/{NVs.Budget.Hosts.Web => NVs.Budget.Hosts.Web.Server}/Program.cs (100%) rename src/Hosts/{NVs.Budget.Hosts.Web => NVs.Budget.Hosts.Web.Server}/Properties/launchSettings.json (100%) rename src/Hosts/{NVs.Budget.Hosts.Web => NVs.Budget.Hosts.Web.Server}/appsettings.json (100%) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/Dockerfile b/src/Hosts/NVs.Budget.Hosts.Web.Client/Dockerfile new file mode 100644 index 00000000..e0886e36 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj", "NVs.Budget.Hosts.Web.Client/"] +RUN dotnet restore "NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj" +COPY . . +WORKDIR "/src/NVs.Budget.Hosts.Web.Client" +RUN dotnet build "NVs.Budget.Hosts.Web.Client.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "NVs.Budget.Hosts.Web.Client.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "NVs.Budget.Hosts.Web.Client.dll"] diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj b/src/Hosts/NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj new file mode 100644 index 00000000..cbdd54b7 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web.Client/Program.cs new file mode 100644 index 00000000..1e225d79 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/Program.cs @@ -0,0 +1,5 @@ +var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); + +app.UseStaticFiles(); +app.Run(); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/Properties/launchSettings.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/Properties/launchSettings.json new file mode 100644 index 00000000..2115ceff --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5062", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7076;http://localhost:5062", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web/appsettings.Development.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/appsettings.Development.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web/appsettings.Development.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/appsettings.Development.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/appsettings.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Dockerfile b/src/Hosts/NVs.Budget.Hosts.Web.Server/Dockerfile similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web/Dockerfile rename to src/Hosts/NVs.Budget.Hosts.Web.Server/Dockerfile diff --git a/src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj b/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj rename to src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web/Program.cs rename to src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs diff --git a/src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json b/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web/Properties/launchSettings.json rename to src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web/appsettings.json b/src/Hosts/NVs.Budget.Hosts.Web.Server/appsettings.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web/appsettings.json rename to src/Hosts/NVs.Budget.Hosts.Web.Server/appsettings.json diff --git a/src/NVs.Budget.sln b/src/NVs.Budget.sln index 03268459..a11ed025 100644 --- a/src/NVs.Budget.sln +++ b/src/NVs.Budget.sln @@ -69,7 +69,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.I EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Utilities.Expressions.Tests", "Utilities\NVs.Budget.Utilities.Expressions.Tests\NVs.Budget.Utilities.Expressions.Tests.csproj", "{7D3E1EE7-0B18-4C90-AFAB-DB5A6D6D433B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Hosts.Web", "Hosts\NVs.Budget.Hosts.Web\NVs.Budget.Hosts.Web.csproj", "{A129265C-EC73-4D2B-A4E8-E7DB6578D7B5}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Hosts.Web.Server", "Hosts\NVs.Budget.Hosts.Web.Server\NVs.Budget.Hosts.Web.Server.csproj", "{A129265C-EC73-4D2B-A4E8-E7DB6578D7B5}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C12A475A-DE78-41F0-A270-A759D23E9403}" EndProject @@ -79,6 +79,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Infrastructure.P EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Controllers.Web", "Controllers\NVs.Budget.Controllers.Web\NVs.Budget.Controllers.Web.csproj", "{EB175F71-CAA9-496A-839A-6597EC28CE7D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Hosts.Web.Client", "Hosts\NVs.Budget.Hosts.Web.Client\NVs.Budget.Hosts.Web.Client.csproj", "{8F054687-62ED-4A70-88FC-5D868673758F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -116,6 +118,7 @@ Global {A090F5DB-DD45-4A1F-844B-EF529B0E56A6} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} {6E2847D7-0B1C-4609-BB15-0AEA262B1123} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} {EB175F71-CAA9-496A-839A-6597EC28CE7D} = {8FBC3B88-0C56-4282-BBF3-607292E95F91} + {8F054687-62ED-4A70-88FC-5D868673758F} = {ABCBF4A8-F237-4243-9A07-27D6B361CCCB} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A757A6B7-3317-493A-B9CB-DA97CCCA6A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -238,5 +241,9 @@ Global {EB175F71-CAA9-496A-839A-6597EC28CE7D}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB175F71-CAA9-496A-839A-6597EC28CE7D}.Release|Any CPU.ActiveCfg = Release|Any CPU {EB175F71-CAA9-496A-839A-6597EC28CE7D}.Release|Any CPU.Build.0 = Release|Any CPU + {8F054687-62ED-4A70-88FC-5D868673758F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F054687-62ED-4A70-88FC-5D868673758F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F054687-62ED-4A70-88FC-5D868673758F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F054687-62ED-4A70-88FC-5D868673758F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From bcda8a933e4daa157e30519d3f9fdb4ed611b04a Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 2 Mar 2025 11:07:59 +0300 Subject: [PATCH 013/156] feat: web client --- .../src/budget-client/.editorconfig | 17 + .../src/budget-client/.gitignore | 42 + .../src/budget-client/.vscode/extensions.json | 4 + .../src/budget-client/.vscode/launch.json | 20 + .../src/budget-client/.vscode/tasks.json | 42 + .../src/budget-client/README.md | 59 + .../src/budget-client/angular.json | 102 + .../src/budget-client/package-lock.json | 14913 ++++++++++++++++ .../src/budget-client/package.json | 37 + .../src/budget-client/public/favicon.ico | Bin 0 -> 15086 bytes .../budget-client/src/app/app.component.css | 0 .../budget-client/src/app/app.component.html | 2 + .../src/app/app.component.spec.ts | 29 + .../budget-client/src/app/app.component.ts | 19 + .../src/budget-client/src/app/app.config.ts | 9 + .../src/budget-client/src/app/app.routes.ts | 3 + .../src/app/auth/auth/auth-response.model.ts | 4 + .../src/app/auth/auth/auth.component.css | 0 .../src/app/auth/auth/auth.component.html | 6 + .../src/app/auth/auth/auth.component.spec.ts | 23 + .../src/app/auth/auth/auth.component.ts | 43 + .../src/app/auth/auth/auth.service.ts | 32 + .../environments/environment.development.ts | 4 + .../src/environments/environment.ts | 4 + .../src/budget-client/src/index.html | 13 + .../src/budget-client/src/main.ts | 6 + .../src/budget-client/src/styles.css | 1 + .../src/budget-client/tsconfig.app.json | 15 + .../src/budget-client/tsconfig.json | 27 + .../src/budget-client/tsconfig.spec.json | 15 + .../NVs.Budget.Hosts.Web.Server/Program.cs | 9 +- .../WebIdentityExtensions.cs | 2 + 32 files changed, 15501 insertions(+), 1 deletion(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.editorconfig create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.gitignore create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/extensions.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/launch.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/tasks.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/README.md create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/angular.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package-lock.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/public/favicon.ico create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.css create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.spec.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.config.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.routes.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth-response.model.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.css create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.spec.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.service.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.development.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/index.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/main.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/styles.css create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.app.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.json create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.spec.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.editorconfig b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.editorconfig new file mode 100644 index 00000000..f166060d --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.gitignore b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.gitignore new file mode 100644 index 00000000..cc7b1413 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.gitignore @@ -0,0 +1,42 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/extensions.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/extensions.json new file mode 100644 index 00000000..77b37457 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 + "recommendations": ["angular.ng-template"] +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/launch.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/launch.json new file mode 100644 index 00000000..925af837 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "ng serve", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: start", + "url": "http://localhost:4200/" + }, + { + "name": "ng test", + "type": "chrome", + "request": "launch", + "preLaunchTask": "npm: test", + "url": "http://localhost:9876/debug.html" + } + ] +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/tasks.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/tasks.json new file mode 100644 index 00000000..a298b5bd --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/tasks.json @@ -0,0 +1,42 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + }, + { + "type": "npm", + "script": "test", + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "pattern": "$tsc", + "background": { + "activeOnStart": true, + "beginsPattern": { + "regexp": "(.*?)" + }, + "endsPattern": { + "regexp": "bundle generation complete" + } + } + } + } + ] +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/README.md b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/README.md new file mode 100644 index 00000000..b3bf0f0b --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/README.md @@ -0,0 +1,59 @@ +# BudgetClient + +This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.0. + +## Development server + +To start a local development server, run: + +```bash +ng serve +``` + +Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. + +## Code scaffolding + +Angular CLI includes powerful code scaffolding tools. To generate a new component, run: + +```bash +ng generate component component-name +``` + +For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: + +```bash +ng generate --help +``` + +## Building + +To build the project run: + +```bash +ng build +``` + +This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. + +## Running unit tests + +To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: + +```bash +ng test +``` + +## Running end-to-end tests + +For end-to-end (e2e) testing, run: + +```bash +ng e2e +``` + +Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. + +## Additional Resources + +For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/angular.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/angular.json new file mode 100644 index 00000000..d95b2549 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/angular.json @@ -0,0 +1,102 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "budget-client": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/budget-client", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "budget-client:build:production" + }, + "development": { + "buildTarget": "budget-client:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package-lock.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package-lock.json new file mode 100644 index 00000000..33b839f5 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package-lock.json @@ -0,0 +1,14913 @@ +{ + "name": "budget-client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "budget-client", + "version": "0.0.0", + "dependencies": { + "@angular/common": "^19.2.0", + "@angular/compiler": "^19.2.0", + "@angular/core": "^19.2.0", + "@angular/forms": "^19.2.0", + "@angular/platform-browser": "^19.2.0", + "@angular/platform-browser-dynamic": "^19.2.0", + "@angular/router": "^19.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^19.2.0", + "@angular/cli": "^19.2.0", + "@angular/compiler-cli": "^19.2.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.6.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.7.2" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@angular-devkit/architect": { + "version": "0.1902.0", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.0.tgz", + "integrity": "sha512-F/3O38QOYCwNqECNQauKb56GYdST9SrRSiqTNc5xpnUL//A09kaucmKSZ2VJAVY7K/rktSQn5viiQ3rTJLiZgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.0", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/architect/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/build-angular": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.0.tgz", + "integrity": "sha512-chPiwTKQPYQn34MZ+5spTCSVSY5vha9C5UKPHsEFNiNT0Iw1mQRJkFvDyq9WZnoc4B0w5KRIiR08EjOTNHj/1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.1902.0", + "@angular-devkit/build-webpack": "0.1902.0", + "@angular-devkit/core": "19.2.0", + "@angular/build": "19.2.0", + "@babel/core": "7.26.9", + "@babel/generator": "7.26.9", + "@babel/helper-annotate-as-pure": "7.25.9", + "@babel/helper-split-export-declaration": "7.24.7", + "@babel/plugin-transform-async-generator-functions": "7.26.8", + "@babel/plugin-transform-async-to-generator": "7.25.9", + "@babel/plugin-transform-runtime": "7.26.9", + "@babel/preset-env": "7.26.9", + "@babel/runtime": "7.26.9", + "@discoveryjs/json-ext": "0.6.3", + "@ngtools/webpack": "19.2.0", + "@vitejs/plugin-basic-ssl": "1.2.0", + "ansi-colors": "4.1.3", + "autoprefixer": "10.4.20", + "babel-loader": "9.2.1", + "browserslist": "^4.21.5", + "copy-webpack-plugin": "12.0.2", + "css-loader": "7.1.2", + "esbuild-wasm": "0.25.0", + "fast-glob": "3.3.3", + "http-proxy-middleware": "3.0.3", + "istanbul-lib-instrument": "6.0.3", + "jsonc-parser": "3.3.1", + "karma-source-map-support": "1.4.0", + "less": "4.2.2", + "less-loader": "12.2.0", + "license-webpack-plugin": "4.0.2", + "loader-utils": "3.3.1", + "mini-css-extract-plugin": "2.9.2", + "open": "10.1.0", + "ora": "5.4.1", + "picomatch": "4.0.2", + "piscina": "4.8.0", + "postcss": "8.5.2", + "postcss-loader": "8.1.1", + "resolve-url-loader": "5.0.0", + "rxjs": "7.8.1", + "sass": "1.85.0", + "sass-loader": "16.0.5", + "semver": "7.7.1", + "source-map-loader": "5.0.0", + "source-map-support": "0.5.21", + "terser": "5.39.0", + "tree-kill": "1.2.2", + "tslib": "2.8.1", + "webpack": "5.98.0", + "webpack-dev-middleware": "7.4.2", + "webpack-dev-server": "5.2.0", + "webpack-merge": "6.0.1", + "webpack-subresource-integrity": "5.1.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "esbuild": "0.25.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", + "@angular/localize": "^19.0.0 || ^19.2.0-next.0", + "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", + "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", + "@angular/ssr": "^19.2.0", + "@web/test-runner": "^0.20.0", + "browser-sync": "^3.0.2", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "karma": "^6.3.0", + "ng-packagr": "^19.0.0 || ^19.2.0-next.0", + "protractor": "^7.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "typescript": ">=5.5 <5.9" + }, + "peerDependenciesMeta": { + "@angular/localize": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "@web/test-runner": { + "optional": true + }, + "browser-sync": { + "optional": true + }, + "jest": { + "optional": true + }, + "jest-environment-jsdom": { + "optional": true + }, + "karma": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "protractor": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/build-angular/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/build-webpack": { + "version": "0.1902.0", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.0.tgz", + "integrity": "sha512-SZsesHqrFRRUHXo4NZ1yZ+RsH/hGMVFoWb65pk+POSJYR4W6nm4pO0B2Uww2FWzv1MFfqYBOig/rBqhMB+yJ7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.1902.0", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "webpack": "^5.30.0", + "webpack-dev-server": "^5.0.2" + } + }, + "node_modules/@angular-devkit/build-webpack/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.0.tgz", + "integrity": "sha512-qd2nYoHZOYWRsu4MjXG8KiDtfM9ZDRR2rDGa+rDZ3CYAsngCrPmqOebun10dncUjwAidX49P4S2U2elOmX3VYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.0.tgz", + "integrity": "sha512-cGGqUGqBXIGJkeL65l70y0BflDAu/0Zi/ohbYat3hvadFfumRJnVElVfJ59JtWO7FfKQjxcwCVTyuQ/tevX/9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.0", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular/build": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.0.tgz", + "integrity": "sha512-IdTA9SvYReNcANm0tMgEtsx8qdIqKZYoF2xPZw2YDh6TeBWZK8VwoWtpXzkOBWedf9vgcrT7y0Y8gB11pgEP6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.1902.0", + "@babel/core": "7.26.9", + "@babel/helper-annotate-as-pure": "7.25.9", + "@babel/helper-split-export-declaration": "7.24.7", + "@babel/plugin-syntax-import-attributes": "7.26.0", + "@inquirer/confirm": "5.1.6", + "@vitejs/plugin-basic-ssl": "1.2.0", + "beasties": "0.2.0", + "browserslist": "^4.23.0", + "esbuild": "0.25.0", + "fast-glob": "3.3.3", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "listr2": "8.2.5", + "magic-string": "0.30.17", + "mrmime": "2.0.1", + "parse5-html-rewriting-stream": "7.0.0", + "picomatch": "4.0.2", + "piscina": "4.8.0", + "rollup": "4.34.8", + "sass": "1.85.0", + "semver": "7.7.1", + "source-map-support": "0.5.21", + "vite": "6.1.0", + "watchpack": "2.4.2" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "optionalDependencies": { + "lmdb": "3.2.6" + }, + "peerDependencies": { + "@angular/compiler": "^19.0.0 || ^19.2.0-next.0", + "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", + "@angular/localize": "^19.0.0 || ^19.2.0-next.0", + "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", + "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", + "@angular/ssr": "^19.2.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^19.0.0 || ^19.2.0-next.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "typescript": ">=5.5 <5.9" + }, + "peerDependenciesMeta": { + "@angular/localize": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@angular/build/node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@angular/build/node_modules/vite": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.5.1", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/@angular/build/node_modules/vite/node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/@angular/cli": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.0.tgz", + "integrity": "sha512-LUxuku6obwigdDJozAvmXyhMcm3rSXFoZK4+Al7r/JE80pjQEE+bGpu7jCb6JsH813DTNauN+BB66qk8bXSgRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.1902.0", + "@angular-devkit/core": "19.2.0", + "@angular-devkit/schematics": "19.2.0", + "@inquirer/prompts": "7.3.2", + "@listr2/prompt-adapter-inquirer": "2.0.18", + "@schematics/angular": "19.2.0", + "@yarnpkg/lockfile": "1.1.0", + "ini": "5.0.0", + "jsonc-parser": "3.3.1", + "listr2": "8.2.5", + "npm-package-arg": "12.0.2", + "npm-pick-manifest": "10.0.0", + "pacote": "20.0.0", + "resolve": "1.22.10", + "semver": "7.7.1", + "symbol-observable": "4.0.0", + "yargs": "17.7.2" + }, + "bin": { + "ng": "bin/ng.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/common": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.0.tgz", + "integrity": "sha512-dm8PR94QY3DucXxltdV5p2Yxyr5bfPlmjOElwLhiTvxWbwCZJTVhPc8dw0TCKzCEu+tKafT48u4BLIB34a0A/g==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/core": "19.2.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.0.tgz", + "integrity": "sha512-xGBD0C9ikH4jVDuQU3XzGqbh9Wovl8UR0wNzNd9rm4fltfC9ipz9NbfetsLPKWpPbfnUqmqMe4/pYjGEgWMonw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/core": "19.2.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + } + } + }, + "node_modules/@angular/compiler-cli": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.0.tgz", + "integrity": "sha512-IFl3LNfFanspS4gHjn207TPuoJGGieuC9r+j3nDitUcFH49fbShYLGCB6xczvK+j68ZWCqv4voxAOmLyfA/Opw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "7.26.9", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^4.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^17.2.1" + }, + "bin": { + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js", + "ngcc": "bundles/ngcc/index.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/compiler": "19.2.0", + "typescript": ">=5.5 <5.9" + } + }, + "node_modules/@angular/core": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.0.tgz", + "integrity": "sha512-WKTRltOt3MMWWuhRX7Y9RonKxIYjZeBDE6XRwceHMgaEDS2d8I2D3AIuqizRsgHpJqDPnQnH+vxcek4FivcSGA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0" + } + }, + "node_modules/@angular/forms": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.0.tgz", + "integrity": "sha512-/GHQgiDPUr1vMXCB1O8c+O70DcoZykDBzOICCaz3kTu46rp48g6E6iaZVJoozI0iBwB8+rnuTPQnLWJ46w+wVg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.0", + "@angular/core": "19.2.0", + "@angular/platform-browser": "19.2.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/platform-browser": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.0.tgz", + "integrity": "sha512-rt3byGZWU0jF6QCLxjP+LH94uL0VM5LgtJ+tYclJqCNB1C3fZrpa86GVd9onVbZmDk0ETUOwm7dQHYdef8oiqw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/animations": "19.2.0", + "@angular/common": "19.2.0", + "@angular/core": "19.2.0" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, + "node_modules/@angular/platform-browser-dynamic": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.0.tgz", + "integrity": "sha512-664OAYxzRYx9WjZu+o0VT+vMM0OqPOb0OnbwnyvIVNBuufWK7/IxWZ/U+Kh9A/XJYpDPtB5N1WEfeiO8AAzWnQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.0", + "@angular/compiler": "19.2.0", + "@angular/core": "19.2.0", + "@angular/platform-browser": "19.2.0" + } + }, + "node_modules/@angular/router": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.0.tgz", + "integrity": "sha512-Md/zleBpWMi5H6KPMREM0M2EUAkoqe01zkXla0Z0hHoTn7Ty0fv0Te9bGDioVOG7JgHh6wYCrPJ/uJsjKObyvw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.0", + "@angular/core": "19.2.0", + "@angular/platform-browser": "19.2.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", + "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/core/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/@babel/core/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/@babel/generator": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", + "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/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/@babel/helper-create-class-features-plugin": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz", + "integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.26.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/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/@babel/helper-create-regexp-features-plugin": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz", + "integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/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/@babel/helper-define-polyfill-provider": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", + "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", + "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", + "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.26.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz", + "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", + "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", + "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", + "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.26.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz", + "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.26.9.tgz", + "integrity": "sha512-Jf+8y9wXQbbxvVYTM8gO5oEF2POdNji0NMltEkG7FtmzD9PVz7/lxpqSdTvwsjTMU5HIHuDVNf2SOxLkWi+wPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/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/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", + "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.26.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz", + "integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", + "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.8", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.26.8", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.26.5", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.26.3", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.26.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.26.3", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.26.8", + "@babel/plugin-transform-typeof-symbol": "^7.26.7", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.11.0", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.40.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-env/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/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", + "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", + "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", + "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.2.tgz", + "integrity": "sha512-PL9ixC5YsPXzXhAZFUPmkXGxfgjkdfZdPEPPmt4kFwQ4LBMDG9n/nHXYRGGZSKZJs+d1sGKWgS2GiPzVRKUdtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", + "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.7", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.7.tgz", + "integrity": "sha512-AA9CQhlrt6ZgiSy6qoAigiA1izOa751ugX6ioSjqgJ+/Gd+tEN/TORk5sUYNjXuHWfW0r1n/a6ak4u/NqHHrtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.7.tgz", + "integrity": "sha512-gktCSQtnSZHaBytkJKMKEuswSk2cDBuXX5rxGFv306mwHfBPjg5UAldw9zWGoEyvA9KpRDkeM4jfrx0rXn0GyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.9.tgz", + "integrity": "sha512-Xxt6nhomWTAmuSX61kVgglLjMEFGa+7+F6UUtdEUeg7fg4r9vaFttUUKrtkViYYrQBA5Ia1tkOJj2koP9BuLig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.10.tgz", + "integrity": "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.6.tgz", + "integrity": "sha512-1f5AIsZuVjPT4ecA8AwaxDFNHny/tSershP/cTvTDxLdiIGTeILNcKozB0LaYt6mojJLUbOYhpIxicaYf7UKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.9.tgz", + "integrity": "sha512-iN2xZvH3tyIYXLXBvlVh0npk1q/aVuKXZo5hj+K3W3D4ngAEq/DkLpofRzx6oebTUhBvOgryZ+rMV0yImKnG3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.9.tgz", + "integrity": "sha512-xBEoOw1XKb0rIN208YU7wM7oJEHhIYkfG7LpTJAEW913GZeaoQerzf5U/LSHI45EVvjAdgNXmXgH51cUXKZcJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.9.tgz", + "integrity": "sha512-+5t6ebehKqgoxV8fXwE49HkSF2Rc9ijNiVGEQZwvbMI61/Q5RcD+jWD6Gs1tKdz5lkI8GRBL31iO0HjGK1bv+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.9.tgz", + "integrity": "sha512-DWmKztkYo9CvldGBaRMr0ETUHgR86zE6sPDVOHsqz4ISe9o1LuiWfgJk+2r75acFclA93J/lqzhT0dTjCzHuoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.0.9.tgz", + "integrity": "sha512-BpJyJe7Dkhv2kz7yG7bPSbJLQuu/rqyNlF1CfiiFeFwouegfH+zh13KDyt6+d9DwucKo7hqM3wKLLyJxZMO+Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/figures": "^1.0.10", + "@inquirer/type": "^3.0.4", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.4.tgz", + "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.1.1.tgz", + "integrity": "sha512-osjeBqMJ2lb/j/M8NCPjs1ylqWIcTRTycIhVB5pt6LgzgeRSb0YRZ7j9RfA8wIUrsr/medIuhVyonXRZWLyfdw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.1", + "@jsonjoy.com/util": "^1.1.2", + "hyperdyperid": "^1.2.0", + "thingies": "^1.20.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.5.0.tgz", + "integrity": "sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.18.tgz", + "integrity": "sha512-0hz44rAcrphyXcA8IS7EJ2SCoaBZD2u5goE8S/e+q/DL+dOGpqpcLidVOFeLG3VgML62SXmfRLAhWt0zL1oW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^1.5.5" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 8" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/@inquirer/type": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", + "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mute-stream": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", + "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", + "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", + "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", + "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", + "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", + "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", + "integrity": "sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.0.1", + "@napi-rs/nice-android-arm64": "1.0.1", + "@napi-rs/nice-darwin-arm64": "1.0.1", + "@napi-rs/nice-darwin-x64": "1.0.1", + "@napi-rs/nice-freebsd-x64": "1.0.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.0.1", + "@napi-rs/nice-linux-arm64-gnu": "1.0.1", + "@napi-rs/nice-linux-arm64-musl": "1.0.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.0.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.0.1", + "@napi-rs/nice-linux-s390x-gnu": "1.0.1", + "@napi-rs/nice-linux-x64-gnu": "1.0.1", + "@napi-rs/nice-linux-x64-musl": "1.0.1", + "@napi-rs/nice-win32-arm64-msvc": "1.0.1", + "@napi-rs/nice-win32-ia32-msvc": "1.0.1", + "@napi-rs/nice-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz", + "integrity": "sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz", + "integrity": "sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz", + "integrity": "sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz", + "integrity": "sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz", + "integrity": "sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngtools/webpack": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.0.tgz", + "integrity": "sha512-63/8ys3bNK2h7Py/dKHZ4ZClxQz6xuz3skUgLZIMs9O076KPsHTKDKEDG2oicmwe/nOXjVt6n9Z4wprFaRLbvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", + "typescript": ">=5.5 <5.9", + "webpack": "^5.54.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/agent/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/git": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", + "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/git/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", + "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", + "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", + "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@npmcli/package-json/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/package-json/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", + "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.1.1.tgz", + "integrity": "sha512-3Hc2KGIkrvJWJqTbvueXzBeZlmvoOxc2jyX00yzr3+sNFquJg0N8hH4SAPLPVrkWIRQICVpVgjrss971awXVnA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.0.2.tgz", + "integrity": "sha512-cJXiUlycdizQwvqE1iaAb4VRUM3RX09/8q46zjvy+ct9GhfZRWd7jXYVc1tn/CfRlGPVkX/u4sstRlepsm7hfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/run-script/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/@npmcli/run-script/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", + "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", + "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", + "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", + "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", + "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", + "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", + "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", + "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", + "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", + "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", + "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", + "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", + "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", + "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", + "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", + "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", + "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", + "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", + "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@schematics/angular": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.0.tgz", + "integrity": "sha512-/gdrYTr1DSUNmrUmpmne6uBnIBpJ/obHtccvz5sZckKni/KMPAr3CgGZ8JrHer3I732ucb1We9nbdtXvz+2glg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.0", + "@angular-devkit/schematics": "19.2.0", + "jsonc-parser": "3.3.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", + "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", + "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.0.tgz", + "integrity": "sha512-o09cLSIq9EKyRXwryWDOJagkml9XgQCoCSRjHOnHLnvsivaW7Qznzz6yjfV7PHJHhIvyp8OH7OX8w0Dc5bQK7A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", + "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.0.tgz", + "integrity": "sha512-suVMQEA+sKdOz5hwP9qNcEjX6B45R+hFFr4LAWzbRc5O+U2IInwvay/bpG5a4s+qR35P/JK/PiKiRGjfuLy1IA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.0.tgz", + "integrity": "sha512-kAAM06ca4CzhvjIZdONAL9+MLppW3K48wOFy1TbuaWFW/OMfl8JuTgW0Bm02JB1WJGT/ET2eqav0KTEKmxqkIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", + "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@tufjs/models/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@tufjs/models/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.16", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", + "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/jasmine": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.7.tgz", + "integrity": "sha512-DVOfk9FaClQfNFpSfaML15jjB5cjffDMvjtph525sroR5BEAW2uKnTOYUTqTFuZFjNvH0T5XMIydvIctnUKufw==", + "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/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.8.tgz", + "integrity": "sha512-G3EfaZS+iOGYWLLRCEAXdWK9my08oHNZ+FHluRiggIYJPOXzhOiDgpVCUHaUvyIC5/fj7C/p637jdzC666AOKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", + "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz", + "integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", + "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abbrev": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", + "integrity": "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-loader": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", + "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.3", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/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/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/beasties": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.2.0.tgz", + "integrity": "sha512-Ljqskqx/tbZagIglYoJIMzH5zgssyp+in9+9sAyh15N22AornBeIDnb8EZ6Rk+6ShfMxd92uO3gfpT0NtZbpow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^9.1.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "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": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001701", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001701.tgz", + "integrity": "sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clone-deep/node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", + "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^3.14.1" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.1", + "globby": "^14.0.0", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/core-js-compat": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz", + "integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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/cross-spawn/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/css-loader": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/custom-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", + "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-format": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", + "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/di": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", + "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-serialize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", + "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "custom-event": "~1.0.0", + "ent": "~2.2.0", + "extend": "^3.0.0", + "void-elements": "^2.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.109", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.109.tgz", + "integrity": "sha512-AidaH9JETVRr9DIPGfp1kAarm/W6hRJTPuCnkF+2MqhF4KaAgRIcBc8nvjk+YMXZhwfISof/7WG29eS4iGxQLQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ent": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", + "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "punycode": "^1.4.1", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/esbuild-wasm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.25.0.tgz", + "integrity": "sha512-60iuWr6jdTVylmGXjpnqk3pCktUi5Rmjiv6EMza3h4X20BLtfL2BjUGs1+UCt2G9UK7jVGrJdUr5i1k0sL3wBg==", + "dev": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + } + }, + "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-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "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/esrecurse/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/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/express/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "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-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-cache-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.2.tgz", + "integrity": "sha512-sYKnA7eGln5ov8T8gnYlkSOxFJvywzEx9BueN6xo/GKO8PGiI6uK6xx+DIGe45T3bdVjLAQDQW1aicT8z8JwQg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.9.tgz", + "integrity": "sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-middleware": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.3.tgz", + "integrity": "sha512-usY0HG5nyDUwtqpiZdETNbmKtw3QQ1jwYFZ9wi5iHzX2BcILwQKtYDJPo7XHTsu5Z0B2Hj3W9NNnbd+AjFWjqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", + "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ignore-walk/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ignore-walk/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immutable": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-network-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", + "integrity": "sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", + "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "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/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jasmine-core": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.6.0.tgz", + "integrity": "sha512-niVlkeYVRwKFpmfWg6suo6H9CrNnydfBLEqefM5UjibYS+UoTjZdmvPJSiuyrRLGnFj1eYRhFd/ch+5hSlsFVA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "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-parse-even-better-errors": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "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/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/karma": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", + "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@colors/colors": "1.5.0", + "body-parser": "^1.19.0", + "braces": "^3.0.2", + "chokidar": "^3.5.1", + "connect": "^3.7.0", + "di": "^0.0.1", + "dom-serialize": "^2.2.1", + "glob": "^7.1.7", + "graceful-fs": "^4.2.6", + "http-proxy": "^1.18.1", + "isbinaryfile": "^4.0.8", + "lodash": "^4.17.21", + "log4js": "^6.4.1", + "mime": "^2.5.2", + "minimatch": "^3.0.4", + "mkdirp": "^0.5.5", + "qjobs": "^1.2.0", + "range-parser": "^1.2.1", + "rimraf": "^3.0.2", + "socket.io": "^4.7.2", + "source-map": "^0.6.1", + "tmp": "^0.2.1", + "ua-parser-js": "^0.7.30", + "yargs": "^16.1.1" + }, + "bin": { + "karma": "bin/karma" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/karma-chrome-launcher": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", + "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "which": "^1.2.1" + } + }, + "node_modules/karma-coverage": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", + "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.0.5", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma-coverage/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/karma-jasmine": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", + "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jasmine-core": "^4.1.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "karma": "^6.0.0" + } + }, + "node_modules/karma-jasmine-html-reporter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", + "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "jasmine-core": "^4.0.0 || ^5.0.0", + "karma": "^6.0.0", + "karma-jasmine": "^5.0.0" + } + }, + "node_modules/karma-jasmine/node_modules/jasmine-core": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", + "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma-source-map-support": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", + "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map-support": "^0.5.5" + } + }, + "node_modules/karma/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/karma/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/karma/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/karma/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/karma/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/karma/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/karma/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/karma/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/karma/node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/karma/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/karma/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/karma/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launch-editor": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.10.0.tgz", + "integrity": "sha512-D7dBRJo/qcGX9xlvt/6wUYzQxjh5G1RvZPgPv8vi4KRU99DVQL/oW7tnVOCCTm2HGeo3C5HvGE5Yrh6UBoZ0vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.8.1" + } + }, + "node_modules/less": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/less/-/less-4.2.2.tgz", + "integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "copy-anything": "^2.0.1", + "parse-node-version": "^1.0.1", + "tslib": "^2.3.0" + }, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=6" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "make-dir": "^2.1.0", + "mime": "^1.4.1", + "needle": "^3.1.0", + "source-map": "~0.6.0" + } + }, + "node_modules/less-loader": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-12.2.0.tgz", + "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "less": "^3.5.0 || ^4.0.0", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/less/node_modules/make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/less/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/less/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/less/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/license-webpack-plugin": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", + "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "webpack-sources": "^3.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-sources": { + "optional": true + } + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/lmdb": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.2.6.tgz", + "integrity": "sha512-SuHqzPl7mYStna8WRotY8XX/EUZBjjv3QyKIByeCLFfC9uXT/OIHByEcA07PzbMfQAM0KYJtLgtpMRlIe5dErQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.2.6", + "@lmdb/lmdb-darwin-x64": "3.2.6", + "@lmdb/lmdb-linux-arm": "3.2.6", + "@lmdb/lmdb-linux-arm64": "3.2.6", + "@lmdb/lmdb-linux-x64": "3.2.6", + "@lmdb/lmdb-win32-x64": "3.2.6" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/log4js": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", + "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "flatted": "^3.2.7", + "rfdc": "^1.3.0", + "streamroller": "^3.1.5" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.0.tgz", + "integrity": "sha512-4eirfZ7thblFmqFjywlTmuWVSvccHAJbn1r8qQLzmTO11qcqpohOjmY2mFce6x7x7WtskzRqApPD0hv+Oa74jg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/json-pack": "^1.0.3", + "@jsonjoy.com/util": "^1.3.0", + "tree-dump": "^1.0.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", + "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.4", + "rimraf": "^5.0.5" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/minizlib/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/minizlib/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minizlib/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/msgpackr": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz", + "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==", + "dev": true, + "license": "MIT", + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "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/needle": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", + "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.3", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-gyp": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.1.0.tgz", + "integrity": "sha512-/+7TuHKnBpnMvUQnsYEb0JOozDZqarQbfNuSGLXIjhStMT0fbw7IdSqWgopOP5xhRZE+lsbIvAHcekddruPZgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/node-gyp/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-bundled": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", + "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-install-checks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", + "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-package-arg": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", + "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-packlist": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", + "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", + "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", + "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", + "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ordered-binary": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.3.tgz", + "integrity": "sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pacote": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", + "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-html-rewriting-stream": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", + "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.3.0", + "parse5": "^7.0.0", + "parse5-sax-parser": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-sax-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", + "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/piscina": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.8.0.tgz", + "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@napi-rs/nice": "^1.0.1" + } + }, + "node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/postcss": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", + "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", + "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.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-loader": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", + "integrity": "sha512-0IeqyAsG6tYiDRCYKQJLAmgQr47DX6N7sFSWvQxt6AcupX8DIdmykuk/o/tx0Lze3ErGHJEp5OSRxrelC6+NdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0", + "jiti": "^1.20.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/qjobs": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", + "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.9" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regex-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", + "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.12.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-url-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/resolve-url-loader/node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", + "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.34.8", + "@rollup/rollup-android-arm64": "4.34.8", + "@rollup/rollup-darwin-arm64": "4.34.8", + "@rollup/rollup-darwin-x64": "4.34.8", + "@rollup/rollup-freebsd-arm64": "4.34.8", + "@rollup/rollup-freebsd-x64": "4.34.8", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", + "@rollup/rollup-linux-arm-musleabihf": "4.34.8", + "@rollup/rollup-linux-arm64-gnu": "4.34.8", + "@rollup/rollup-linux-arm64-musl": "4.34.8", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", + "@rollup/rollup-linux-riscv64-gnu": "4.34.8", + "@rollup/rollup-linux-s390x-gnu": "4.34.8", + "@rollup/rollup-linux-x64-gnu": "4.34.8", + "@rollup/rollup-linux-x64-musl": "4.34.8", + "@rollup/rollup-win32-arm64-msvc": "4.34.8", + "@rollup/rollup-win32-ia32-msvc": "4.34.8", + "@rollup/rollup-win32-x64-msvc": "4.34.8", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", + "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.5.tgz", + "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/schema-utils": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", + "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "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/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", + "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "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/source-map-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" + } + }, + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/streamroller": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", + "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "date-format": "^4.0.14", + "debug": "^4.3.4", + "fs-extra": "^8.1.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/terser": { + "version": "5.39.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", + "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.12.tgz", + "integrity": "sha512-jDLYqo7oF8tJIttjXO6jBY5Hk8p3A8W4ttih7cCEq64fQFWmgJ4VqAQjKr7WwIDlmXKEc6QeoRb5ecjZ+2afcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/thingies": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", + "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-dump": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", + "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "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==", + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", + "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-assert": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", + "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "0.7.40", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.40.tgz", + "integrity": "sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", + "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", + "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite/node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "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", + "peer": true, + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/webpack": { + "version": "5.98.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", + "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", + "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.6.0", + "mime-types": "^2.1.31", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz", + "integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.21.2", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.7", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.6.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^2.4.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/webpack-dev-server/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-subresource-integrity": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", + "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "typed-assert": "^1.0.8" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "html-webpack-plugin": ">= 5.0.0-beta.1 < 6", + "webpack": "^5.12.0" + }, + "peerDependenciesMeta": { + "html-webpack-plugin": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "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/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zone.js": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", + "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==", + "license": "MIT" + } + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package.json new file mode 100644 index 00000000..61c36865 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package.json @@ -0,0 +1,37 @@ +{ + "name": "budget-client", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "ng test" + }, + "private": true, + "dependencies": { + "@angular/common": "^19.2.0", + "@angular/compiler": "^19.2.0", + "@angular/core": "^19.2.0", + "@angular/forms": "^19.2.0", + "@angular/platform-browser": "^19.2.0", + "@angular/platform-browser-dynamic": "^19.2.0", + "@angular/router": "^19.2.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^19.2.0", + "@angular/cli": "^19.2.0", + "@angular/compiler-cli": "^19.2.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.6.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.7.2" + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/public/favicon.ico b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.css new file mode 100644 index 00000000..e69de29b diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.html new file mode 100644 index 00000000..7a9e1a21 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.html @@ -0,0 +1,2 @@ + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.spec.ts new file mode 100644 index 00000000..5c868949 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it(`should have the 'budget-client' title`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('budget-client'); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain('Hello, budget-client'); + }); +}); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.ts new file mode 100644 index 00000000..078d2b73 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.ts @@ -0,0 +1,19 @@ +import { Component, enableProdMode } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { AuthComponent } from './auth/auth/auth.component'; +import { environment } from '../environments/environment'; +import { AuthService } from './auth/auth/auth.service'; + +if (environment.production) { + enableProdMode(); + } + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, AuthComponent], + templateUrl: './app.component.html', + styleUrl: './app.component.css' +}) +export class AppComponent { + title = 'budget-client'; +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.config.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.config.ts new file mode 100644 index 00000000..7247182b --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.config.ts @@ -0,0 +1,9 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { routes } from './app.routes'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; + +export const appConfig: ApplicationConfig = { + providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(withInterceptorsFromDi())] +}; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.routes.ts new file mode 100644 index 00000000..dc39edb5 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.routes.ts @@ -0,0 +1,3 @@ +import { Routes } from '@angular/router'; + +export const routes: Routes = []; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth-response.model.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth-response.model.ts new file mode 100644 index 00000000..45f16c17 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth-response.model.ts @@ -0,0 +1,4 @@ +// auth-response.model.ts +export interface AuthResponse { + isAuthenticated: boolean; + } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.css new file mode 100644 index 00000000..e69de29b diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.html new file mode 100644 index 00000000..8e185d22 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.html @@ -0,0 +1,6 @@ +
+ +
+ \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.spec.ts new file mode 100644 index 00000000..365dab28 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AuthComponent } from './auth.component'; + +describe('AuthComponent', () => { + let component: AuthComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AuthComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AuthComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.ts new file mode 100644 index 00000000..d024c27f --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.ts @@ -0,0 +1,43 @@ +// auth-status.component.ts +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { AuthService } from './auth.service'; +import { CommonModule } from '@angular/common'; +import { environment } from '../../../environments/environment' + +@Component({ + selector: 'app-auth', + templateUrl: './auth.component.html', + styleUrls: ['./auth.component.css'], + imports: [CommonModule], +}) +export class AuthComponent implements OnInit { + isAuthenticated = false; + + constructor( + private authService: AuthService, + private router: Router + ) {} + + ngOnInit() { + this.authService.setBaseUrl(environment.apiUrl); // Задаем базовый URL сервера + this.checkAuthentication(); + } + + get BaseUrl() { + return this.authService.BaseUrl; + } + + checkAuthentication() { + this.authService.whoAmI().subscribe(response => { + this.isAuthenticated = response.isAuthenticated; + }); + } + + logout() { + this.authService.logout().subscribe(() => { + this.router.navigate(['/']); // Переходим на главную страницу + window.location.reload(); // Перезагружаем страницу после выхода + }); + } +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.service.ts new file mode 100644 index 00000000..404608c7 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.service.ts @@ -0,0 +1,32 @@ +// auth.service.ts +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { AuthResponse } from './auth-response.model'; + +@Injectable({ providedIn: 'root' }) +export class AuthService { + private baseUrl: string = ""; + + constructor(private http: HttpClient) {} + + get BaseUrl(): string { + return this.baseUrl; + } + + setBaseUrl(url: string): void { + this.baseUrl = url; + } + + whoAmI(): Observable { + return this.http.get(this.buildUrl('auth/whoami')); + } + + logout(): Observable { + return this.http.post(this.buildUrl('auth/logout'), {}); + } + + private buildUrl(endpoint: string): string { + return `${this.baseUrl}/${endpoint}`; + } +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.development.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.development.ts new file mode 100644 index 00000000..c444f62e --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.development.ts @@ -0,0 +1,4 @@ +export const environment = { + production: false, + apiUrl: 'https://localhost:7237' +}; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.ts new file mode 100644 index 00000000..cfeea543 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.ts @@ -0,0 +1,4 @@ +export const environment = { + production: true, + apiUrl: 'http://localhost:3000' +}; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/index.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/index.html new file mode 100644 index 00000000..1bb66b09 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/index.html @@ -0,0 +1,13 @@ + + + + + BudgetClient + + + + + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/main.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/main.ts new file mode 100644 index 00000000..35b00f34 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/styles.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/styles.css new file mode 100644 index 00000000..90d4ee00 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.app.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.app.json new file mode 100644 index 00000000..3775b37e --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.json new file mode 100644 index 00000000..5525117c --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.json @@ -0,0 +1,27 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.spec.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.spec.json new file mode 100644 index 00000000..5fb748d9 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs index 973d9e5d..6f842f45 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs @@ -32,12 +32,19 @@ .AddTransient(p => p.GetRequiredService().CreateReckoner()) .AddApplicationUseCases() .AddSingleton(new Factory().CreateProvider()) + .AddCors(opts => + { + if (builder.Environment.IsDevelopment()) + { + opts.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); + } + }) .AddWebControllers(); var app = builder.Build(); app.UseYandexAuth("/"); - +app.UseCors(); app.MapGet("/", () => "OK"); app.MapGet("/admin/patch-db", async (IEnumerable migrators, CancellationToken ct) => { diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs index c06fb92b..cb109030 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs @@ -65,6 +65,8 @@ public static IServiceCollection AddYandexAuth(this IServiceCollection services, services.AddAuthorization(); services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => { + o.Cookie.HttpOnly = false; + o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; o.ForwardChallenge = OpenIddictClientAspNetCoreDefaults.AuthenticationScheme; }); From 5536e9e69810e402315ecf4574b4418c59b32182 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Sun, 2 Mar 2025 11:11:01 +0300 Subject: [PATCH 014/156] chore: updated paths a bit --- .../{src => }/budget-client/.editorconfig | 0 .../{src => }/budget-client/.gitignore | 0 .../{src => }/budget-client/.vscode/extensions.json | 0 .../{src => }/budget-client/.vscode/launch.json | 0 .../{src => }/budget-client/.vscode/tasks.json | 0 .../{src => }/budget-client/README.md | 0 .../{src => }/budget-client/angular.json | 0 .../{src => }/budget-client/package-lock.json | 0 .../{src => }/budget-client/package.json | 0 .../{src => }/budget-client/public/favicon.ico | Bin .../budget-client/src/app/app.component.css | 0 .../budget-client/src/app/app.component.html | 0 .../budget-client/src/app/app.component.spec.ts | 0 .../budget-client/src/app/app.component.ts | 0 .../{src => }/budget-client/src/app/app.config.ts | 0 .../{src => }/budget-client/src/app/app.routes.ts | 0 .../src/app/auth/auth/auth-response.model.ts | 0 .../src/app/auth/auth/auth.component.css | 0 .../src/app/auth/auth/auth.component.html | 0 .../src/app/auth/auth/auth.component.spec.ts | 0 .../src/app/auth/auth/auth.component.ts | 0 .../budget-client/src/app/auth/auth/auth.service.ts | 0 .../src/environments/environment.development.ts | 0 .../budget-client/src/environments/environment.ts | 0 .../{src => }/budget-client/src/index.html | 0 .../{src => }/budget-client/src/main.ts | 0 .../{src => }/budget-client/src/styles.css | 0 .../{src => }/budget-client/tsconfig.app.json | 0 .../{src => }/budget-client/tsconfig.json | 0 .../{src => }/budget-client/tsconfig.spec.json | 0 30 files changed, 0 insertions(+), 0 deletions(-) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/.editorconfig (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/.gitignore (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/.vscode/extensions.json (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/.vscode/launch.json (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/.vscode/tasks.json (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/README.md (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/angular.json (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/package-lock.json (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/package.json (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/public/favicon.ico (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/app.component.css (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/app.component.html (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/app.component.spec.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/app.component.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/app.config.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/app.routes.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/auth/auth/auth-response.model.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/auth/auth/auth.component.css (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/auth/auth/auth.component.html (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/auth/auth/auth.component.spec.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/auth/auth/auth.component.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/app/auth/auth/auth.service.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/environments/environment.development.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/environments/environment.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/index.html (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/main.ts (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/src/styles.css (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/tsconfig.app.json (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/tsconfig.json (100%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/{src => }/budget-client/tsconfig.spec.json (100%) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.editorconfig b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.editorconfig similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.editorconfig rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.editorconfig diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.gitignore b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.gitignore similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.gitignore rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.gitignore diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/extensions.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.vscode/extensions.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/extensions.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.vscode/extensions.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/launch.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.vscode/launch.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/launch.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.vscode/launch.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/tasks.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.vscode/tasks.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/.vscode/tasks.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.vscode/tasks.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/README.md b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/README.md similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/README.md rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/README.md diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/angular.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/angular.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package-lock.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package-lock.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/package.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/public/favicon.ico b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/public/favicon.ico similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/public/favicon.ico rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/public/favicon.ico diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.css similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.css rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.css diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.html rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.spec.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.spec.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.spec.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.component.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.config.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.config.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.config.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.config.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/app.routes.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth-response.model.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth-response.model.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth-response.model.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth-response.model.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.css similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.css rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.css diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.html rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.spec.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.spec.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.spec.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.component.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/app/auth/auth/auth.service.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.development.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/environments/environment.development.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.development.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/environments/environment.development.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/environments/environment.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/environments/environment.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/environments/environment.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/index.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/index.html similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/index.html rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/index.html diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/main.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/main.ts similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/main.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/main.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/styles.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.css similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/src/styles.css rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.css diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.app.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/tsconfig.app.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.app.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/tsconfig.app.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/tsconfig.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/tsconfig.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.spec.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/tsconfig.spec.json similarity index 100% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/src/budget-client/tsconfig.spec.json rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/tsconfig.spec.json From 6262bcf2e01e53be699c40148bae941519ad6e2b Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Sun, 2 Mar 2025 18:53:24 +0300 Subject: [PATCH 015/156] fix: finally fixed auth, works for HTTPS --- .../NVs.Budget.Hosts.Web.Client.csproj | 1 - .../budget-client/.gitignore | 2 ++ .../budget-client/angular.json | 7 ++++++- .../src/app/auth/auth/auth.component.html | 2 +- .../src/app/auth/auth/auth.component.ts | 7 ------- .../src/app/auth/auth/auth.service.ts | 4 ++-- .../NVs.Budget.Hosts.Web.Server.csproj | 1 + .../NVs.Budget.Hosts.Web.Server/Program.cs | 15 ++++++++------- .../appsettings.Development.json | 17 +++++++++++++++++ 9 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Server/appsettings.Development.json diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj b/src/Hosts/NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj index cbdd54b7..411f7b19 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj @@ -14,7 +14,6 @@
- diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.gitignore b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.gitignore index cc7b1413..02c59d9f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.gitignore +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.gitignore @@ -40,3 +40,5 @@ testem.log # System files .DS_Store Thumbs.db +/certificates/dev/nginx.crt +/certificates/dev/nginx.key diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json index d95b2549..befbe836 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json @@ -71,7 +71,12 @@ "buildTarget": "budget-client:build:development" } }, - "defaultConfiguration": "development" + "defaultConfiguration": "development", + "options": { + "ssl": true, + "sslCert": "./certificates/dev/nginx.crt", + "sslKey": "./certificates/dev/nginx.key" + } }, "extract-i18n": { "builder": "@angular-devkit/build-angular:extract-i18n" diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html index 8e185d22..69627fce 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html @@ -1,5 +1,5 @@
- + Выйти
Войти diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts index d024c27f..147091d6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts @@ -33,11 +33,4 @@ export class AuthComponent implements OnInit { this.isAuthenticated = response.isAuthenticated; }); } - - logout() { - this.authService.logout().subscribe(() => { - this.router.navigate(['/']); // Переходим на главную страницу - window.location.reload(); // Перезагружаем страницу после выхода - }); - } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts index 404608c7..02fa87ee 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts @@ -19,11 +19,11 @@ export class AuthService { } whoAmI(): Observable { - return this.http.get(this.buildUrl('auth/whoami')); + return this.http.get(this.buildUrl('auth/whoami'), { withCredentials: true }); } logout(): Observable { - return this.http.post(this.buildUrl('auth/logout'), {}); + return this.http.get(this.buildUrl('auth/logout'), { withCredentials: true }); } private buildUrl(endpoint: string): string { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj b/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj index 9c3f0e1f..0df59e1b 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj @@ -5,6 +5,7 @@ enable enable Linux + a456c0d1-4c8b-44a5-95c9-27dd13084fbf diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs index 6f842f45..25a92cfa 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs @@ -16,8 +16,9 @@ builder.Services.AddLogging(b => b.AddSerilog(dispose: true)); var identityConnectionString = builder.Configuration.GetConnectionString("IdentityContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!"); var contentConnectionString = builder.Configuration.GetConnectionString("BudgetContext") ?? throw new InvalidOperationException("No connection string found for BudgetContext!"); - var yandexAuthConfig = builder.Configuration.GetSection("Auth:Yandex").Get() ?? throw new InvalidOperationException("No Auth config found for Yandex provider!"); +var frontendUrl = builder.Configuration.GetSection("FrontendUrl").Get() ?? throw new InvalidOperationException("No FrontendUrl config found!"); + builder.Services .AddEfCorePersistence( contentConnectionString, @@ -34,18 +35,18 @@ .AddSingleton(new Factory().CreateProvider()) .AddCors(opts => { - if (builder.Environment.IsDevelopment()) - { - opts.AddDefaultPolicy(policy => policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); - } + var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? string.Empty; + opts.AddDefaultPolicy(b => b.WithOrigins(allowedOrigins.Split(';')).AllowCredentials().AllowAnyHeader().AllowAnyMethod()); }) + .AddWebControllers(); + var app = builder.Build(); -app.UseYandexAuth("/"); +app.UseYandexAuth(frontendUrl); app.UseCors(); -app.MapGet("/", () => "OK"); +app.MapGet("/", () => Results.Redirect(frontendUrl)); app.MapGet("/admin/patch-db", async (IEnumerable migrators, CancellationToken ct) => { foreach (var migrator in migrators) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/appsettings.Development.json b/src/Hosts/NVs.Budget.Hosts.Web.Server/appsettings.Development.json new file mode 100644 index 00000000..fd1723be --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "ConnectionStrings": { + "BudgetContext": "User ID=postgres;Password=postgres;Host=localhost;Port=20000;Database=budgetdb;", + "IdentityContext": "User ID=postgres;Password=postgres;Host=localhost;Port=20000;Database=budgetdb;" + }, + + "AllowedHosts": "localhost", + "AllowedOrigins": "https://localhost;https://localhost:4200;https://localhost:7237", + "FrontendUrl": "https://localhost:4200", + + "Auth": { + "Yandex": { + "ClientId": "...", + "ClientSecret": "..." + } + } +} From 7cf5846755f2c2a8c81f1c5528a7330b51aedba0 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Sun, 2 Mar 2025 22:40:04 +0300 Subject: [PATCH 016/156] feat: frontend app with navbar (by taiga-ui) --- .../budget-client/angular.json | 13 +- .../budget-client/package-lock.json | 711 ++++++++++++++++-- .../budget-client/package.json | 10 + .../budget-client/src/app/app.component.css | 0 .../budget-client/src/app/app.component.html | 25 +- .../budget-client/src/app/app.component.ts | 5 +- .../budget-client/src/app/app.config.ts | 4 +- .../src/app/auth/auth/auth-response.model.ts | 8 +- .../src/app/auth/auth/auth.component.css | 0 .../src/app/auth/auth/auth.component.html | 4 +- .../src/app/auth/auth/auth.component.ts | 18 +- .../src/app/auth/auth/auth.service.ts | 4 - .../src/app/auth/user.service.spec.ts | 16 + .../src/app/auth/user.service.ts | 27 + .../budget-client/src/styles.css | 1 - .../budget-client/src/styles.less | 2 + .../WebIdentityExtensions.cs | 4 +- .../WhoamiResponse.cs | 4 +- 18 files changed, 766 insertions(+), 90 deletions(-) delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.css delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.css create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/user.service.spec.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/user.service.ts delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.css create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.less diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json index befbe836..e5617932 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json @@ -5,7 +5,11 @@ "projects": { "budget-client": { "projectType": "application", - "schematics": {}, + "schematics": { + "@schematics/angular:component": { + "style": "less" + } + }, "root": "", "sourceRoot": "src", "prefix": "app", @@ -24,10 +28,15 @@ { "glob": "**/*", "input": "public" + }, + { + "glob": "**/*", + "input": "node_modules/@taiga-ui/icons/src", + "output": "assets/taiga-ui/icons" } ], "styles": [ - "src/styles.css" + "src/styles.less" ], "scripts": [] }, diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json index 33b839f5..9b87005e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json @@ -8,6 +8,7 @@ "name": "budget-client", "version": "0.0.0", "dependencies": { + "@angular/cdk": "^19.0.0", "@angular/common": "^19.2.0", "@angular/compiler": "^19.2.0", "@angular/core": "^19.2.0", @@ -15,6 +16,15 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", + "@taiga-ui/addon-charts": "^4.26.1", + "@taiga-ui/addon-commerce": "^4.26.1", + "@taiga-ui/addon-table": "^4.26.1", + "@taiga-ui/cdk": "^4.26.1", + "@taiga-ui/core": "^4.26.1", + "@taiga-ui/event-plugins": "^4.0.2", + "@taiga-ui/icons": "^4.26.1", + "@taiga-ui/kit": "^4.26.1", + "@taiga-ui/layout": "^4.26.1", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -243,7 +253,7 @@ "version": "19.2.0", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.0.tgz", "integrity": "sha512-qd2nYoHZOYWRsu4MjXG8KiDtfM9ZDRR2rDGa+rDZ3CYAsngCrPmqOebun10dncUjwAidX49P4S2U2elOmX3VYQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ajv": "8.17.1", @@ -271,7 +281,7 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -281,7 +291,7 @@ "version": "19.2.0", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.0.tgz", "integrity": "sha512-cGGqUGqBXIGJkeL65l70y0BflDAu/0Zi/ohbYat3hvadFfumRJnVElVfJ59JtWO7FfKQjxcwCVTyuQ/tevX/9A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@angular-devkit/core": "19.2.0", @@ -300,12 +310,28 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" } }, + "node_modules/@angular/animations": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.0.tgz", + "integrity": "sha512-GJDwtZ+7XmAAbzCbPSJrR1iMs2l16VoA7myeVl6n5k/KsZywqb4KhPmjzLKpQlAFP0NRjg1LbHc2Fsus7/Ydag==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/core": "19.2.0" + } + }, "node_modules/@angular/build": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.0.tgz", @@ -930,6 +956,23 @@ "@esbuild/win32-x64": "0.24.2" } }, + "node_modules/@angular/cdk": { + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.1.tgz", + "integrity": "sha512-j7dg18PJIbyeU4DTko3vIK3M2OuUv3H0ZViNddOaLlGN5X93cq4QCGcNhcGm3x3r5rUr/AaexYu+KHMyN8PwmA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "optionalDependencies": { + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@angular/common": "^19.0.0 || ^20.0.0", + "@angular/core": "^19.0.0 || ^20.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.0.tgz", @@ -3643,7 +3686,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -3844,6 +3887,50 @@ "win32" ] }, + "node_modules/@maskito/angular": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.3.0.tgz", + "integrity": "sha512-gClGPssn5S9eYaA/U1DlybNo5rGOCbdCXmrPrT1JuZ24yECsmAMabFaAbgDNFe0q+vy9PoujMxpw13lB2y85oA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@angular/forms": ">=16.0.0", + "@maskito/core": "^3.3.0" + } + }, + "node_modules/@maskito/core": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.3.0.tgz", + "integrity": "sha512-eIMLHIv+l1ymuD6ZQqu23dZxSYYZa9BEVbpaSKFaNbGoM2DswB4e4XVzbDSsBFHSefT6lyFdUtm/kzUNjnm+ww==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@maskito/kit": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.3.0.tgz", + "integrity": "sha512-WwWDVnKljlYXLIyy5jw5gGXVoBmk8ipeClvNFUgDTuAsuFqpRonFlwwDCiZJ1suWLZX2JsIvRJdYPzbVSKLcYQ==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@maskito/core": "^3.3.0" + } + }, + "node_modules/@maskito/phone": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.3.0.tgz", + "integrity": "sha512-f1VWpBltLyLAvK5PiCnNGi9uLcWipqGwzbn+dyvN1+B+dZVmmTnYkOmHwPcx/sNlY74V98nW2bEPZnw5+LserA==", + "license": "Apache-2.0", + "peer": true, + "peerDependencies": { + "@maskito/core": "^3.3.0", + "@maskito/kit": "^3.3.0", + "libphonenumber-js": ">=1.0.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -4233,6 +4320,88 @@ "node": ">= 10" } }, + "node_modules/@ng-web-apis/common": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-4.11.1.tgz", + "integrity": "sha512-fXbcMrd/+L+9j9knbgXbDwYe30H4Wt0hQzvqyhpXTVrc0jYwlk3MJTYrnazKz5HvP9318caEv5n4qt3HMf5uPQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@ng-web-apis/intersection-observer": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-4.11.1.tgz", + "integrity": "sha512-KjODVVx20yG/U5bnPvp5voihL5DSVFuYwZVY9DNRvaFIcQPMy1tL1t9/oJOdxj7zUSFDL8+Z0RoJbsvArezuSg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@ng-web-apis/common": ">=4.11.1" + } + }, + "node_modules/@ng-web-apis/mutation-observer": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-4.11.1.tgz", + "integrity": "sha512-YFnkGFE0gd03q4HBJL+WPl3YZRZNq7dVV8yD5uqr0ZCDgmOAMBilrp42FuHBPaYkF73Rs2EpKsKHWz1jASqBbQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@ng-web-apis/common": ">=4.11.1" + } + }, + "node_modules/@ng-web-apis/platform": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@ng-web-apis/platform/-/platform-4.11.1.tgz", + "integrity": "sha512-BrhkUIEEAD7wcwR65LSXHYOD6L3IvAb4aV94S8tzxUNeGUPwikX5glQJBT1UwkHWXQjANPKTCNyK1LO+cMPgkw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + } + }, + "node_modules/@ng-web-apis/resize-observer": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-4.11.1.tgz", + "integrity": "sha512-q8eJ6sovnMhfqIULN1yyhqT35Y2a60vB42p9CUBWPeeVammU+QHE/imPCMCJgnti9cdPZfnyI/+TeYNIUg7mzg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@ng-web-apis/common": ">=4.11.1" + } + }, + "node_modules/@ng-web-apis/screen-orientation": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@ng-web-apis/screen-orientation/-/screen-orientation-4.11.1.tgz", + "integrity": "sha512-HS/kWTgVjXVDqMLcJbl5uty+1sV10m9PeDag74tzktIDAB06diFQJQGfcQaA0o0IBisT3fOysf9gHV5sXxSOFw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@ng-web-apis/common": ">=4.11.1", + "rxjs": ">=7.0.0" + } + }, "node_modules/@ngtools/webpack": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.0.tgz", @@ -4254,7 +4423,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -4268,7 +4437,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -4278,7 +4447,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -5177,7 +5346,7 @@ "version": "19.2.0", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.0.tgz", "integrity": "sha512-/gdrYTr1DSUNmrUmpmne6uBnIBpJ/obHtccvz5sZckKni/KMPAr3CgGZ8JrHer3I732ucb1We9nbdtXvz+2glg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@angular-devkit/core": "19.2.0", @@ -5290,6 +5459,275 @@ "dev": true, "license": "MIT" }, + "node_modules/@taiga-ui/addon-charts": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.26.1.tgz", + "integrity": "sha512-Xs4eiImEj1A6cRVhkn5QBXejeaQ8x/j8MYqwM1+warFELo29ZrPKBy9GX1c95aDLeZ/xJiBlRdIowp05o707Eg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "@ng-web-apis/common": "^4.11.1", + "@taiga-ui/cdk": "^4.26.1", + "@taiga-ui/core": "^4.26.1", + "@taiga-ui/polymorpheus": "^4.8.0" + } + }, + "node_modules/@taiga-ui/addon-commerce": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.26.1.tgz", + "integrity": "sha512-WQ84kAe7Czqap5OpW6/SX14+0XDnipfy46nrloZDBzLjdTw8GczvS2WM5VNba3a/ygv0aPxMPBTBhCeHB0le9Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "@angular/forms": ">=16.0.0", + "@maskito/angular": "^3.2.1", + "@maskito/core": "^3.2.1", + "@maskito/kit": "^3.2.1", + "@ng-web-apis/common": "^4.11.1", + "@taiga-ui/cdk": "^4.26.1", + "@taiga-ui/core": "^4.26.1", + "@taiga-ui/i18n": "^4.26.1", + "@taiga-ui/kit": "^4.26.1", + "@taiga-ui/polymorpheus": "^4.8.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/addon-table": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.26.1.tgz", + "integrity": "sha512-8RBQkm6uLRTDpJtATL6pjqQfwyNcibLbv1bPF6eWl3hNvSuU2FcO/crKJLjuCmJwXcQoXIJCtlh6mEnoqOv+oQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "@ng-web-apis/intersection-observer": "^4.11.1", + "@taiga-ui/cdk": "^4.26.1", + "@taiga-ui/core": "^4.26.1", + "@taiga-ui/i18n": "^4.26.1", + "@taiga-ui/kit": "^4.26.1", + "@taiga-ui/polymorpheus": "^4.8.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/cdk": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.26.1.tgz", + "integrity": "sha512-JVPS7xt1Dcl+hTfrbQzcFkpmEGBqG/gNDxVO+qjScxY6qdOySNgtpepM95ca2InP8Wz+JPd6A2918JvlHvRyDw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.8.1" + }, + "optionalDependencies": { + "@angular-devkit/core": ">=16.0.0", + "@angular-devkit/schematics": ">=16.0.0", + "@schematics/angular": ">=16.0.0", + "ng-morph": "^4.8.4", + "parse5": ">=7.2.1" + }, + "peerDependencies": { + "@angular/animations": ">=16.0.0", + "@angular/cdk": ">=16.0.0", + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "@angular/forms": ">=16.0.0", + "@ng-web-apis/common": "^4.11.1", + "@ng-web-apis/mutation-observer": "^4.11.1", + "@ng-web-apis/platform": "^4.11.1", + "@ng-web-apis/resize-observer": "^4.11.1", + "@ng-web-apis/screen-orientation": "^4.11.1", + "@taiga-ui/event-plugins": "^4.4.1", + "@taiga-ui/polymorpheus": "^4.8.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/core": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.26.1.tgz", + "integrity": "sha512-C2H3mY0+2a5/TvF1fbwVT3B49xAQ0oF07wSm0S7Odb2Xjd3xQ5o6Eygm//E2WmoUH5J+hOVwqce+2lDnEgiTKA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/animations": ">=16.0.0", + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "@angular/forms": ">=16.0.0", + "@angular/platform-browser": ">=16.0.0", + "@angular/router": ">=16.0.0", + "@ng-web-apis/common": "^4.11.1", + "@ng-web-apis/mutation-observer": "^4.11.1", + "@taiga-ui/cdk": "^4.26.1", + "@taiga-ui/event-plugins": "^4.4.1", + "@taiga-ui/i18n": "^4.26.1", + "@taiga-ui/polymorpheus": "^4.8.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/event-plugins": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.4.1.tgz", + "integrity": "sha512-gwEkgyZsbAdRfmb98KlKWivYVF88eP0bOtbHwfj8Ec8DgJ5809qFqeWvJEIxZZ829iox1m8z2UuVrqN2/tI1tQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@angular/platform-browser": ">=16.0.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/i18n": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.26.1.tgz", + "integrity": "sha512-WUvjh7pegWePFt6fnKt68L7SnGx8tJOSdxLIFvLemtvaTk4c7c3xlMhhYMuMV0OkWx/TFOxbhBOsLAyLYv5udQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@ng-web-apis/common": "^4.11.1", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/icons": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.26.1.tgz", + "integrity": "sha512-rVkAZGlZVLk52ow6EuaiFNdBRVLXtedwrMIwIk6KobJM5aZ/COB485xu5f5/IsdIlQ1Nj6S5Ai+4LeCR9ByJAA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.3.0" + } + }, + "node_modules/@taiga-ui/kit": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.26.1.tgz", + "integrity": "sha512-JQw47wsW/8U2WobIeIZNN0NUfCaPjy9EaS4rV8YKnUqxlQKz72x7Nn6dkkS6qyD3HepPIKuK6FvW4B9trWFnZw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "@angular/forms": ">=16.0.0", + "@angular/router": ">=16.0.0", + "@maskito/angular": "^3.2.1", + "@maskito/core": "^3.2.1", + "@maskito/kit": "^3.2.1", + "@maskito/phone": "^3.2.1", + "@ng-web-apis/common": "^4.11.1", + "@ng-web-apis/intersection-observer": "^4.11.1", + "@ng-web-apis/mutation-observer": "^4.11.1", + "@ng-web-apis/resize-observer": "^4.11.1", + "@taiga-ui/cdk": "^4.26.1", + "@taiga-ui/core": "^4.26.1", + "@taiga-ui/i18n": "^4.26.1", + "@taiga-ui/polymorpheus": "^4.8.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/layout": { + "version": "4.26.1", + "resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.26.1.tgz", + "integrity": "sha512-yzTNxteBO+iqF9QHSMcasWpZG7y2l8FrLncy72FqHDIPYJP8jo/gTPqRS/dYgfHCeGyHZtxaRKPXh1rbYFbGBQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "@taiga-ui/cdk": "^4.26.1", + "@taiga-ui/core": "^4.26.1", + "@taiga-ui/kit": "^4.26.1", + "@taiga-ui/polymorpheus": "^4.8.0", + "rxjs": ">=7.0.0" + } + }, + "node_modules/@taiga-ui/polymorpheus": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.8.0.tgz", + "integrity": "sha512-gNXk8SVxXf/5wtmm6XeFMQ9RzY0xbM9E4vFxSGwnNegVZtv3T08YX2uoxPgUbgck2/GS9N5B5KvjjbVa0T0L9A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@angular/core": ">=16.0.0", + "@angular/platform-browser": ">=16.0.0" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.24.0.tgz", + "integrity": "sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.4", + "mkdirp": "^3.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@ts-morph/common/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -5498,6 +5936,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", + "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", + "license": "MIT", + "optional": true + }, "node_modules/@types/node": { "version": "22.13.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.8.tgz", @@ -5877,7 +6322,7 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5894,7 +6339,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ajv": "^8.0.0" @@ -5977,7 +6422,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -6023,6 +6468,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/array-differ": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", + "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -6030,6 +6485,26 @@ "dev": true, "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -6142,14 +6617,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -6230,7 +6705,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -6302,7 +6777,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6313,7 +6788,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -6359,7 +6834,7 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -6611,7 +7086,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -6687,7 +7162,7 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6815,7 +7290,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8" @@ -6849,11 +7324,18 @@ "node": ">=0.10.0" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT", + "optional": true + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6866,7 +7348,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/colorette": { @@ -6953,7 +7435,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/connect": { @@ -7325,7 +7807,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "clone": "^1.0.2" @@ -7649,7 +8131,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -8053,14 +8535,14 @@ "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, + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -8077,7 +8559,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -8090,7 +8572,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -8107,7 +8589,7 @@ "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -8130,7 +8612,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -8547,7 +9029,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8837,7 +9319,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -8967,7 +9449,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/ini": { @@ -9060,7 +9542,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9083,7 +9565,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -9115,7 +9597,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -9138,7 +9620,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -9190,7 +9672,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -9458,7 +9940,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json5": { @@ -9478,7 +9960,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/jsonfile": { @@ -9964,6 +10446,13 @@ "node": ">=0.10.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.4.tgz", + "integrity": "sha512-vLmhg7Gan7idyAKfc6pvCtNzvar4/eIzrVVk3hjNFH5+fGqyjD0gQRovdTrDl20wsmZhBtmZpcsR0tOfquwb8g==", + "license": "MIT", + "peer": true + }, "node_modules/license-webpack-plugin": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", @@ -10126,7 +10615,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -10270,7 +10759,7 @@ "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -10376,7 +10865,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -10396,7 +10885,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -10410,7 +10899,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -10459,7 +10948,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -10510,7 +10999,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -10824,6 +11313,26 @@ "multicast-dns": "cli.js" } }, + "node_modules/multimatch": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz", + "integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/minimatch": "^3.0.3", + "array-differ": "^3.0.0", + "array-union": "^2.1.0", + "arrify": "^2.0.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -10902,6 +11411,50 @@ "dev": true, "license": "MIT" }, + "node_modules/ng-morph": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/ng-morph/-/ng-morph-4.8.4.tgz", + "integrity": "sha512-XwL53wCOhyaAxvoekN74ONbWUK30huzp+GpZYyC01RfaG2AX9l7YlC1mGG/l7Rx7YXtFAk85VFnNJqn2e46K8g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "jsonc-parser": "3.3.1", + "minimatch": "10.0.1", + "multimatch": "5.0.0", + "ts-morph": "23.0.0" + }, + "peerDependencies": { + "@angular-devkit/core": ">=16.0.0", + "@angular-devkit/schematics": ">=16.0.0", + "tslib": "^2.7.0" + } + }, + "node_modules/ng-morph/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/ng-morph/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/node-addon-api": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", @@ -11347,7 +11900,7 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -11371,7 +11924,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11381,7 +11934,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -11394,7 +11947,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -11410,7 +11963,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -11424,14 +11977,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/ora/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11623,7 +12176,7 @@ "version": "7.2.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "entities": "^4.5.0" @@ -11670,6 +12223,13 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT", + "optional": true + }, "node_modules/path-exists": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", @@ -11762,7 +12322,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -12060,7 +12620,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -12117,7 +12677,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -12258,7 +12818,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12375,7 +12935,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -12462,7 +13022,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -12495,7 +13055,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -13217,7 +13777,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">= 8" @@ -13405,7 +13965,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -13529,7 +14089,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -13763,7 +14323,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -13809,6 +14369,17 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-morph": { + "version": "23.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-23.0.0.tgz", + "integrity": "sha512-FcvFx7a9E8TUe6T3ShihXJLiJOiqyafzFKUO4aqIHDUCIvADdGNShcbc2W5PMr3LerXRv7mafvFZ9lRENxJmug==", + "license": "MIT", + "optional": true, + "dependencies": { + "@ts-morph/common": "~0.24.0", + "code-block-writer": "^13.0.1" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -14050,7 +14621,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -14245,7 +14816,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "defaults": "^1.0.3" diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json index 61c36865..66a63790 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json @@ -10,6 +10,7 @@ }, "private": true, "dependencies": { + "@angular/cdk": "^19.0.0", "@angular/common": "^19.2.0", "@angular/compiler": "^19.2.0", "@angular/core": "^19.2.0", @@ -17,6 +18,15 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", + "@taiga-ui/addon-charts": "^4.26.1", + "@taiga-ui/addon-commerce": "^4.26.1", + "@taiga-ui/addon-table": "^4.26.1", + "@taiga-ui/cdk": "^4.26.1", + "@taiga-ui/core": "^4.26.1", + "@taiga-ui/event-plugins": "^4.0.2", + "@taiga-ui/icons": "^4.26.1", + "@taiga-ui/kit": "^4.26.1", + "@taiga-ui/layout": "^4.26.1", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html index 7a9e1a21..4c407a6f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html @@ -1,2 +1,23 @@ - - + +
+ The Budget. + +
+
+ +

Work in progress

+ + We'll have content here, at some point... + + +
+ +
+
\ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts index 078d2b73..72ca3515 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts @@ -1,8 +1,9 @@ +import { TuiButton, TuiRoot } from "@taiga-ui/core"; +import { TuiBlockStatus, TuiNavigation } from "@taiga-ui/layout" import { Component, enableProdMode } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { AuthComponent } from './auth/auth/auth.component'; import { environment } from '../environments/environment'; -import { AuthService } from './auth/auth/auth.service'; if (environment.production) { enableProdMode(); @@ -10,7 +11,7 @@ if (environment.production) { @Component({ selector: 'app-root', - imports: [RouterOutlet, AuthComponent], + imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, TuiButton], templateUrl: './app.component.html', styleUrl: './app.component.css' }) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.config.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.config.ts index 7247182b..d30b66bc 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.config.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.config.ts @@ -1,3 +1,5 @@ +import { NG_EVENT_PLUGINS } from "@taiga-ui/event-plugins"; +import { provideAnimations } from "@angular/platform-browser/animations"; import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; @@ -5,5 +7,5 @@ import { routes } from './app.routes'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; export const appConfig: ApplicationConfig = { - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(withInterceptorsFromDi())] + providers: [provideAnimations(), provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(withInterceptorsFromDi()), NG_EVENT_PLUGINS] }; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth-response.model.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth-response.model.ts index 45f16c17..9576d484 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth-response.model.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth-response.model.ts @@ -1,4 +1,10 @@ -// auth-response.model.ts export interface AuthResponse { isAuthenticated: boolean; + user?: { + id: string + }; + owner?: { + id: string; + name: string; + } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html index 69627fce..ef234293 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts index 147091d6..2d302212 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts @@ -4,19 +4,21 @@ import { Router } from '@angular/router'; import { AuthService } from './auth.service'; import { CommonModule } from '@angular/common'; import { environment } from '../../../environments/environment' +import { UserService } from '../user.service'; +import { TuiLink } from '@taiga-ui/core'; @Component({ selector: 'app-auth', templateUrl: './auth.component.html', styleUrls: ['./auth.component.css'], - imports: [CommonModule], + imports: [CommonModule, TuiLink], }) export class AuthComponent implements OnInit { isAuthenticated = false; constructor( private authService: AuthService, - private router: Router + private user: UserService ) {} ngOnInit() { @@ -31,6 +33,18 @@ export class AuthComponent implements OnInit { checkAuthentication() { this.authService.whoAmI().subscribe(response => { this.isAuthenticated = response.isAuthenticated; + if (response.isAuthenticated) { + this.user.setCurrentUser({ + isAuthenticated: true, + id: response.user!.id, + ownerInfo: response.owner + }) + } + else { + this.user.setCurrentUser({ + isAuthenticated: false + }) + } }); } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts index 02fa87ee..d19f4865 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.service.ts @@ -22,10 +22,6 @@ export class AuthService { return this.http.get(this.buildUrl('auth/whoami'), { withCredentials: true }); } - logout(): Observable { - return this.http.get(this.buildUrl('auth/logout'), { withCredentials: true }); - } - private buildUrl(endpoint: string): string { return `${this.baseUrl}/${endpoint}`; } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/user.service.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/user.service.spec.ts new file mode 100644 index 00000000..3f804c9f --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/user.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { UserService } from './user.service'; + +describe('UserService', () => { + let service: UserService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(UserService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/user.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/user.service.ts new file mode 100644 index 00000000..062e9ba7 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/user.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + private readonly user = new BehaviorSubject({isAuthenticated: false}); + + get current$() { return this.user.asObservable(); } + + constructor() { } + + setCurrentUser(user: User) { this.user.next(user); } +} + +export interface User { + isAuthenticated: boolean; + + id?: string; + ownerInfo?: Owner; +} + +export interface Owner { + id: string; + name: string; +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.css b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.css deleted file mode 100644 index 90d4ee00..00000000 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.css +++ /dev/null @@ -1 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.less new file mode 100644 index 00000000..3e87c958 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/styles.less @@ -0,0 +1,2 @@ +@import 'node_modules/@taiga-ui/core/styles/taiga-ui-theme.less'; +@import 'node_modules/@taiga-ui/core/styles/taiga-ui-fonts.less'; \ No newline at end of file diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs index cb109030..15935324 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WebIdentityExtensions.cs @@ -110,8 +110,8 @@ public static WebApplication UseYandexAuth(this WebApplication app, string authR { var result = await context.AuthenticateAsync(); return result.Succeeded - ? Results.Ok(new WhoamiResponse(true, cache.CachedUser)) - : Results.Ok(new WhoamiResponse(false, null)); + ? Results.Ok(new WhoamiResponse(true, cache.CachedUser, cache.CachedUser.AsOwner())) + : Results.Ok(new WhoamiResponse(false, null, null)); }); app.MapGet(URIs.LogoutUri, async (HttpContext context) => diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WhoamiResponse.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WhoamiResponse.cs index 32420fd5..f2560b74 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WhoamiResponse.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/WhoamiResponse.cs @@ -1,8 +1,10 @@ using NVs.Budget.Application.Contracts.Entities; +using NVs.Budget.Domain.Entities.Accounts; namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; internal record WhoamiResponse( bool IsAuthenticated, - IUser? User + IUser? User, + Owner? Owner ); From dd7ac8b4073e778d42a0165b1469583a836f29e5 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Mon, 3 Mar 2025 09:15:30 +0300 Subject: [PATCH 017/156] fix: added missed files --- .../budget-client/src/app/app.component.less | 3 +++ .../budget-client/src/app/auth/auth/auth.component.less | 0 2 files changed, 3 insertions(+) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.less diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less new file mode 100644 index 00000000..5d2e8e8f --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less @@ -0,0 +1,3 @@ +.authLink { + float: right; +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.less new file mode 100644 index 00000000..e69de29b From d83b3e8e16b0c3ca4b1f737eb85fbc2f522bf3be Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Mon, 3 Mar 2025 09:43:05 +0300 Subject: [PATCH 018/156] chore: tweaked styles --- .../budget-client/src/app/app.component.html | 9 +++++++-- .../budget-client/src/app/app.component.less | 5 +++-- .../budget-client/src/app/app.component.ts | 14 ++++++++++++-- .../src/app/auth/auth/auth.component.html | 8 ++------ .../src/app/auth/auth/auth.component.less | 3 +++ .../src/app/auth/auth/auth.component.ts | 2 +- 6 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html index 4c407a6f..85b71238 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html @@ -1,7 +1,12 @@ -
+
The Budget. - + + + {{ownerName$ | async}} + + +
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less index 5d2e8e8f..6daac8f3 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less @@ -1,3 +1,4 @@ -.authLink { - float: right; +.flex-space-between { + display:flex; + justify-content: space-between; } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts index 72ca3515..c91f8958 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts @@ -4,6 +4,9 @@ import { Component, enableProdMode } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { AuthComponent } from './auth/auth/auth.component'; import { environment } from '../environments/environment'; +import { UserService } from "./auth/user.service"; +import { CommonModule } from "@angular/common"; +import { map } from "rxjs"; if (environment.production) { enableProdMode(); @@ -11,10 +14,17 @@ if (environment.production) { @Component({ selector: 'app-root', - imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, TuiButton], + imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, TuiButton, CommonModule], templateUrl: './app.component.html', - styleUrl: './app.component.css' + styleUrl: './app.component.less' }) export class AppComponent { title = 'budget-client'; + + get currentUser$() { return this.user.current$; } + get isAuthenticated$() { return this.user.current$.pipe(map(u => u.isAuthenticated)); } + get userId$() { return this.user.current$.pipe(map(u => u.id)); } + get ownerName$() { return this.user.current$.pipe(map(u => u.ownerInfo?.name)); } + + constructor(private user: UserService) { } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html index ef234293..b84fa547 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html @@ -1,6 +1,2 @@ - - \ No newline at end of file +Выйти +Войти \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.less index e69de29b..33761d1a 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.less @@ -0,0 +1,3 @@ +a { + padding-left: 5pt; +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts index 2d302212..ec1805a8 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts @@ -10,7 +10,7 @@ import { TuiLink } from '@taiga-ui/core'; @Component({ selector: 'app-auth', templateUrl: './auth.component.html', - styleUrls: ['./auth.component.css'], + styleUrls: ['./auth.component.less'], imports: [CommonModule, TuiLink], }) export class AuthComponent implements OnInit { From 56710a884d7a74f0d1edff19ed910329dc45a4f7 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Mon, 3 Mar 2025 14:19:33 +0300 Subject: [PATCH 019/156] wip: budgets api --- .../Controllers/BudgetController.cs | 96 +++++++++++++++++-- .../Controllers/BudgetResponse.cs | 10 ++ .../Controllers/CreateBudgetRequest.cs | 3 + .../Controllers/UpdateBudgetRequest.cs | 10 ++ .../MappingProfile.cs | 31 ++++++ .../NVs.Budget.Controllers.Web.csproj | 4 + .../Utils/StringStreamUtils.cs | 8 ++ .../WebControllersExtensions.cs | 20 +++- .../src/app/budget/budget-api.service.spec.ts | 16 ++++ .../src/app/budget/budget-api.service.ts | 9 ++ .../budget-selector.component.html | 1 + .../budget-selector.component.less | 0 .../budget-selector.component.spec.ts | 23 +++++ .../budget-selector.component.ts | 11 +++ .../src/app/budget/budget/budget.model.ts | 7 ++ .../NVs.Budget.Hosts.Web.Server/Program.cs | 4 +- 16 files changed, 241 insertions(+), 12 deletions(-) create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetResponse.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/CreateBudgetRequest.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/UpdateBudgetRequest.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Utils/StringStreamUtils.cs create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.spec.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.spec.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget/budget.model.ts diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index 00aece12..fc7a4a74 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -1,35 +1,111 @@ using Asp.Versioning; -using MediatR; +using AutoMapper; +using FluentResults; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Application.Contracts.Services; -using NVs.Budget.Application.Contracts.UseCases.Accounts; +using NVs.Budget.Controllers.Web.Utils; +using NVs.Budget.Infrastructure.IO.Console.Input; namespace NVs.Budget.Controllers.Web.Controllers; [Authorize] [ApiVersion("0.1")] [Route("api/{version:apiVersion}/[controller]")] -public class BudgetController(IBudgetManager manager ) : Controller +public class BudgetController( + IBudgetManager manager, + IMapper mapper, + ITaggingCriteriaReader tagsReader, + ITransferCriteriaReader transfersReader, + ILogbookCriteriaReader logbookReader +) : Controller { [HttpGet] - public Task> GetBudgets(CancellationToken ct) + public async Task> GetBudgets(CancellationToken ct) { - return manager.GetOwnedBudgets(ct); + var budgets = await manager.GetOwnedBudgets(ct); + return mapper.Map>(budgets); } [HttpGet("{id:guid}")] - public async Task GetBudget(Guid id, CancellationToken ct) + public async Task GetBudget(Guid id, CancellationToken ct) { var budgets = await manager.GetOwnedBudgets(ct); - return budgets.FirstOrDefault(b => b.Id == id); + return mapper.Map(budgets.FirstOrDefault(b => b.Id == id)); } [HttpPost] - public async Task CreateBudget([FromBody] string name, CancellationToken ct) + public async Task CreateBudget([FromBody] CreateBudgetRequest request, CancellationToken ct) { - var result = await manager.Register(new(name), ct); - return result.IsSuccess ? Ok(result.Value) : BadRequest(result.Errors); + var result = await manager.Register(new(request.Name), ct); + return result.IsSuccess ? Ok(mapper.Map(result.Value)) : BadRequest(result.Errors); + } + + [HttpPut] + public async Task UpdateBudget([FromBody] UpdateBudgetRequest request, CancellationToken ct) + { + var budget = (await manager.GetOwnedBudgets(ct)).FirstOrDefault(b => b.Id == request.Id); + if (budget == null) + { + return BadRequest("Budget not found"); + } + + var errors = new List(); + var tags = new List(); + var transfers = new List(); + LogbookCriteria? logbook = null; + + using (var reader = request.TaggingCriteria.AsStreamReader()) + { + await foreach (var tagResult in tagsReader.ReadFrom(reader, ct)) + { + if (tagResult.IsSuccess) + { + tags.Add(tagResult.Value); + } + else + { + errors.AddRange(tagResult.Errors); + } + } + } + + using (var reader = request.TransferCriteria.AsStreamReader()) + { + await foreach (var transferResult in transfersReader.ReadFrom(reader, ct)) + { + if (transferResult.IsSuccess) + { + transfers.Add(transferResult.Value); + } + else + { + errors.AddRange(transferResult.Errors); + } + } + } + + using (var reader = request.LogbookCriteria.AsStreamReader()) + { + var logbookResult = await logbookReader.ReadFrom(reader, ct); + if (logbookResult.IsSuccess) + { + logbook = logbookResult.Value; + } + else + { + errors.AddRange(logbookResult.Errors); + } + } + + if (errors.Any()) + { + return BadRequest(errors); + } + + var result = await manager.Update(new(request.Id, request.Name, budget.Owners, tags, transfers, logbook!), ct); + return result.IsSuccess ? NoContent() : BadRequest(result.Errors); } } diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetResponse.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetResponse.cs new file mode 100644 index 00000000..d327224e --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetResponse.cs @@ -0,0 +1,10 @@ +namespace NVs.Budget.Controllers.Web.Controllers; + +public record BudgetResponse( + Guid Id, + string Name, + string Version, + string TaggingCriteria, + string TransferCriteria, + string LogbookCriteria + ); diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CreateBudgetRequest.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CreateBudgetRequest.cs new file mode 100644 index 00000000..d5ceb22f --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CreateBudgetRequest.cs @@ -0,0 +1,3 @@ +namespace NVs.Budget.Controllers.Web.Controllers; + +public record CreateBudgetRequest(string Name); diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/UpdateBudgetRequest.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/UpdateBudgetRequest.cs new file mode 100644 index 00000000..6ca3c0f6 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/UpdateBudgetRequest.cs @@ -0,0 +1,10 @@ +namespace NVs.Budget.Controllers.Web.Controllers; + +public record UpdateBudgetRequest( + Guid Id, + string Name, + string Version, + string TaggingCriteria, + string TransferCriteria, + string LogbookCriteria +); \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs b/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs new file mode 100644 index 00000000..221f08ad --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs @@ -0,0 +1,31 @@ +using AutoMapper; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Controllers.Web.Controllers; +using NVs.Budget.Domain.Entities.Operations; +using NVs.Budget.Utilities.Expressions; + +namespace NVs.Budget.Controllers.Web; + +internal class MappingProfile : Profile +{ + public MappingProfile(ReadableExpressionsParser parser) + { + CreateMap>, string>().ConstructUsing(r => r.ToString()); + CreateMap>>().ConstructUsing( + r => parser.ParseUnaryConversion(r).Value + ); + CreateMap>, string>().ConstructUsing(r => r.ToString()); + CreateMap>>().ConstructUsing( + r => parser.ParseUnaryPredicate(r).Value + ); + CreateMap>, string>().ConstructUsing(r => r.ToString()); + CreateMap>>().ConstructUsing(r => parser.ParseUnaryPredicate(r).Value); + CreateMap>, string>().ConstructUsing(r => r.ToString()); + CreateMap>>().ConstructUsing(r => parser.ParseUnaryConversion(r).Value); + + CreateMap>, string>().ConstructUsing(r => r.ToString()); + CreateMap>>().ConstructUsing(r => parser.ParseBinaryPredicate(r).Value); + + CreateMap(); + } +} \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj b/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj index 0bb190b0..dd16e92f 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj +++ b/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj @@ -8,8 +8,11 @@ + + + @@ -18,6 +21,7 @@ + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Utils/StringStreamUtils.cs b/src/Controllers/NVs.Budget.Controllers.Web/Utils/StringStreamUtils.cs new file mode 100644 index 00000000..a9582b43 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Utils/StringStreamUtils.cs @@ -0,0 +1,8 @@ +using System.Text; + +namespace NVs.Budget.Controllers.Web.Utils; + +internal static class StringStreamUtils +{ + public static StreamReader AsStreamReader(this string s) => new(new MemoryStream(Encoding.UTF8.GetBytes(s))); +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs index a6efadc8..48480fcc 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs @@ -2,13 +2,19 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; +using NVs.Budget.Utilities.Expressions; namespace NVs.Budget.Controllers.Web; public static class WebControllersExtensions { - public static IServiceCollection AddWebControllers(this IServiceCollection services) + public static IServiceCollection AddWebControllers(this IServiceCollection services, ReadableExpressionsParser parser) { + services.AddAutoMapper(m => m.AddProfile(new MappingProfile(parser))); + + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(); + var assembly = typeof(WebControllersExtensions).Assembly; var part = new AssemblyPart(assembly); services @@ -17,6 +23,18 @@ public static IServiceCollection AddWebControllers(this IServiceCollection servi services.AddApiVersioning(); + return services; } + + public static WebApplication UseWebControllers(this WebApplication app, bool useSwagger) + { + if (useSwagger) + { + app.UseSwagger(); + app.UseSwaggerUI(); + } + + return app; + } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.spec.ts new file mode 100644 index 00000000..3a43257f --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { BudgetApiService } from './budget-api.service'; + +describe('BudgetApiService', () => { + let service: BudgetApiService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(BudgetApiService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts new file mode 100644 index 00000000..45426342 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts @@ -0,0 +1,9 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class BudgetApiService { + + constructor() { } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html new file mode 100644 index 00000000..f7d9d3dd --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html @@ -0,0 +1 @@ +

budget-selector works!

diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less new file mode 100644 index 00000000..e69de29b diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.spec.ts new file mode 100644 index 00000000..ce2ae0f0 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BudgetSelectorComponent } from './budget-selector.component'; + +describe('BudgetSelectorComponent', () => { + let component: BudgetSelectorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BudgetSelectorComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(BudgetSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts new file mode 100644 index 00000000..0c81d4c9 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-budget-selector', + imports: [], + templateUrl: './budget-selector.component.html', + styleUrl: './budget-selector.component.less' +}) +export class BudgetSelectorComponent { + +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget/budget.model.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget/budget.model.ts new file mode 100644 index 00000000..b8d61eb2 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget/budget.model.ts @@ -0,0 +1,7 @@ +export interface Budget { + id: string; + name: string; + taggingCriteria: string; + transferCriteria: string; + logbookCriteria: string; +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs index 25a92cfa..016d61ea 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs @@ -39,12 +39,14 @@ opts.AddDefaultPolicy(b => b.WithOrigins(allowedOrigins.Split(';')).AllowCredentials().AllowAnyHeader().AllowAnyMethod()); }) - .AddWebControllers(); + .AddWebControllers(ReadableExpressionsParser.Default); var app = builder.Build(); app.UseYandexAuth(frontendUrl); +app.UseWebControllers(app.Environment.IsDevelopment()); + app.UseCors(); app.MapGet("/", () => Results.Redirect(frontendUrl)); app.MapGet("/admin/patch-db", async (IEnumerable migrators, CancellationToken ct) => From 832c8173771cc4ff07e9e65a6be3ecdf13da460f Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Mon, 3 Mar 2025 21:18:08 +0300 Subject: [PATCH 020/156] feat: new budget --- .../Controllers/BudgetController.cs | 12 +++- .../WebControllersExtensions.cs | 36 +++++++++- .../budget-client/src/app/app.component.html | 19 ++--- .../budget-client/src/app/app.component.less | 4 ++ .../budget-client/src/app/app.component.ts | 4 +- .../budget-client/src/app/app.routes.ts | 5 +- .../src/app/budget/budget-api.service.spec.ts | 71 +++++++++++++++++-- .../src/app/budget/budget-api.service.ts | 38 +++++++++- .../src/app/budget/budget/budget.model.ts | 7 -- .../budget-client/src/app/budget/models.ts | 23 ++++++ .../new-budget/new-budget.component.html | 29 ++++++++ .../new-budget/new-budget.component.less | 0 .../new-budget/new-budget.component.spec.ts | 23 ++++++ .../budget/new-budget/new-budget.component.ts | 56 +++++++++++++++ .../NVs.Budget.Hosts.Web.Server.csproj | 1 + .../NVs.Budget.Hosts.Web.Server/Program.cs | 6 +- 16 files changed, 294 insertions(+), 40 deletions(-) delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget/budget.model.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.spec.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index fc7a4a74..1011e131 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -1,7 +1,9 @@ +using System.Net; using Asp.Versioning; using AutoMapper; using FluentResults; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Application.Contracts.Entities.Budgeting; @@ -13,7 +15,7 @@ namespace NVs.Budget.Controllers.Web.Controllers; [Authorize] [ApiVersion("0.1")] -[Route("api/{version:apiVersion}/[controller]")] +[Route("api/v{version:apiVersion}/[controller]")] public class BudgetController( IBudgetManager manager, IMapper mapper, @@ -36,14 +38,18 @@ public async Task> GetBudgets(CancellationTo return mapper.Map(budgets.FirstOrDefault(b => b.Id == id)); } - [HttpPost] + [HttpPost, + ProducesResponseType(StatusCodes.Status200OK), + ProducesResponseType>(StatusCodes.Status400BadRequest)] public async Task CreateBudget([FromBody] CreateBudgetRequest request, CancellationToken ct) { var result = await manager.Register(new(request.Name), ct); return result.IsSuccess ? Ok(mapper.Map(result.Value)) : BadRequest(result.Errors); } - [HttpPut] + [HttpPut, + ProducesResponseType(StatusCodes.Status204NoContent), + ProducesResponseType>(StatusCodes.Status400BadRequest)] public async Task UpdateBudget([FromBody] UpdateBudgetRequest request, CancellationToken ct) { var budget = (await manager.GetOwnedBudgets(ct)).FirstOrDefault(b => b.Id == request.Id); diff --git a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs index 48480fcc..b7632992 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs @@ -1,8 +1,12 @@ -using Microsoft.AspNetCore.Builder; +using System.Reflection; +using Asp.Versioning; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; using NVs.Budget.Utilities.Expressions; +using Swashbuckle.AspNetCore.SwaggerGen; namespace NVs.Budget.Controllers.Web; @@ -13,7 +17,33 @@ public static IServiceCollection AddWebControllers(this IServiceCollection servi services.AddAutoMapper(m => m.AddProfile(new MappingProfile(parser))); services.AddEndpointsApiExplorer(); - services.AddSwaggerGen(); + services.AddSwaggerGen(o => + { + o.SwaggerDoc("v0.1", new OpenApiInfo { Title = "Budget API", Version = "v0.1" }); + o.DocInclusionPredicate((docName, apiDesc) => + { + if (!apiDesc.TryGetMethodInfo(out MethodInfo methodInfo)) + return false; + var versions = methodInfo.DeclaringType! + .GetCustomAttributes(true) + .OfType() + .SelectMany(attr => attr.Versions); + var versionMatched = versions.Any(v => $"v{v}" == docName); + if (versionMatched) + { + if (apiDesc.RelativePath?.StartsWith("api/v{version}/") == true) + { + apiDesc.RelativePath = apiDesc.RelativePath.Replace("api/v{version}/", $"api/{docName}/"); + var versionParam = apiDesc.ParameterDescriptions + .SingleOrDefault(p => p.Name == "version" && p.Source.Id == "Path"); + if (versionParam != null) + apiDesc.ParameterDescriptions.Remove(versionParam); + } + } + + return versionMatched; + }); + }); var assembly = typeof(WebControllersExtensions).Assembly; var part = new AssemblyPart(assembly); @@ -32,7 +62,7 @@ public static WebApplication UseWebControllers(this WebApplication app, bool use if (useSwagger) { app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwaggerUI(o => o.SwaggerEndpoint("/swagger/v0.1/swagger.json", "Budget API v0.1")); } return app; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html index 85b71238..3b0b6d5d 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html @@ -1,6 +1,9 @@
- The Budget. + + The Budget. + + + {{ownerName$ | async}} @@ -9,20 +12,6 @@
- -

Work in progress

- - We'll have content here, at some point... - - -
\ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less index 6daac8f3..23a2e126 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less @@ -1,4 +1,8 @@ .flex-space-between { display:flex; justify-content: space-between; +} + +.flex { + display:flex; } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts index c91f8958..7757c687 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts @@ -1,7 +1,7 @@ import { TuiButton, TuiRoot } from "@taiga-ui/core"; import { TuiBlockStatus, TuiNavigation } from "@taiga-ui/layout" import { Component, enableProdMode } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { RouterLink, RouterOutlet } from '@angular/router'; import { AuthComponent } from './auth/auth/auth.component'; import { environment } from '../environments/environment'; import { UserService } from "./auth/user.service"; @@ -14,7 +14,7 @@ if (environment.production) { @Component({ selector: 'app-root', - imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, TuiButton, CommonModule], + imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, CommonModule, RouterLink], templateUrl: './app.component.html', styleUrl: './app.component.less' }) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts index dc39edb5..6bd9a3e4 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts @@ -1,3 +1,6 @@ import { Routes } from '@angular/router'; +import { NewBudgetComponent } from './budget/new-budget/new-budget.component'; -export const routes: Routes = []; +export const routes: Routes = [ + { path: 'budget/new', component: NewBudgetComponent } +]; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.spec.ts index 3a43257f..3d4ac049 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.spec.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.spec.ts @@ -1,16 +1,77 @@ import { TestBed } from '@angular/core/testing'; - +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { BudgetApiService } from './budget-api.service'; +import { BudgetResponse, CreateBudgetRequest, UpdateBudgetRequest } from './models'; describe('BudgetApiService', () => { let service: BudgetApiService; + let httpMock: HttpTestingController; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [BudgetApiService, provideHttpClientTesting()] + }); + service = TestBed.inject(BudgetApiService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); // Проверяем, что все ожидаемые запросы были выполнены + }); + + it('should get all budgets', () => { + const mockData: BudgetResponse[] = [ + { id: '123', name: 'Test Budget' }, + { id: '456', name: 'Another Budget' } + ]; + + service.getAllBudgets().subscribe((data: BudgetResponse[]) => { + expect(data).toEqual(mockData); // Проверка, что возвращаемые данные совпадают с ожидаемыми + }); + + const req = httpMock.expectOne({ method: 'GET', url: '/api/v0.1/Budget' }); + expect(req.request.withCredentials).toBe(true); // Проверяем, что с запросом отправляются куки + req.flush(mockData); // Эмулируем успешный ответ сервера + }); + + it('should create a new budget', () => { + const request: CreateBudgetRequest = { name: 'New Budget' }; + const expectedResponse: BudgetResponse = { id: '789', name: 'New Budget' }; + + service.createBudget(request).subscribe((response: BudgetResponse) => { + expect(response).toEqual(expectedResponse); + }); + + const req = httpMock.expectOne({ method: 'POST', url: '/api/v0.1/Budget' }); + expect(req.request.body).toEqual(request); // Проверяем, что тело запроса совпадает с ожидаемым + expect(req.request.headers.has('Content-Type')).toBeTruthy(); // Проверяем наличие заголовка Content-Type + expect(req.request.withCredentials).toBe(true); // Проверяем, что с запросом отправляются куки + req.flush(expectedResponse); // Эмулируем успешный ответ сервера }); - it('should be created', () => { - expect(service).toBeTruthy(); + it('should update an existing budget', () => { + const id = '123'; + const request: UpdateBudgetRequest = { id, name: 'Updated Budget' }; + + service.updateBudget(id, request).subscribe(() => {}); + + const req = httpMock.expectOne({ method: 'PUT', url: `/api/v0.1/Budget/${id}` }); + expect(req.request.body).toEqual(request); // Проверяем, что тело запроса совпадает с ожидаемым + expect(req.request.withCredentials).toBe(true); // Проверяем, что с запросом отправляются куки + req.flush(null); // Эмулируем успешный ответ сервера (без тела) + }); + + it('should get a specific budget by id', () => { + const id = '123'; + const mockData: BudgetResponse = { id: '123', name: 'Test Budget' }; + + service.getBudgetById(id).subscribe((data) => { + expect(data).toEqual(mockData); // Проверка, что возвращаемые данные совпадают с ожидаемыми + }); + + const req = httpMock.expectOne({ method: 'GET', url: `/api/v0.1/Budget/${id}` }); + expect(req.request.withCredentials).toBe(true); // Проверяем, что с запросом отправляются куки + req.flush(mockData); // Эмулируем успешный ответ сервера }); -}); +}); \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts index 45426342..854ad563 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts @@ -1,9 +1,43 @@ import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { BudgetResponse, CreateBudgetRequest, UpdateBudgetRequest } from './models'; // Импортируйте модели, соответствующие схеме OpenAPI +import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class BudgetApiService { + private baseUrl = environment.apiUrl + '/api/v0.1'; // Ваш базовый URL для запросов - constructor() { } -} + constructor(private http: HttpClient) {} + + /** + * Получение списка бюджетов + */ + getAllBudgets(): Observable { + return this.http.get(`${this.baseUrl}/budget`, { withCredentials: true }); + } + + /** + * Создание нового бюджета + */ + createBudget(request: CreateBudgetRequest): Observable { + const headers = new HttpHeaders().set('Content-Type', 'application/json'); + return this.http.post(`${this.baseUrl}/budget`, request, { headers, withCredentials: true }); + } + + /** + * Обновление существующего бюджета + */ + updateBudget(id: string, request: UpdateBudgetRequest): Observable { + return this.http.put(`${this.baseUrl}/budget/${id}`, request, { withCredentials: true }); + } + + /** + * Получение конкретного бюджета по ID + */ + getBudgetById(id: string): Observable { + return this.http.get(`${this.baseUrl}/budget/${id}`, { withCredentials: true }); + } +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget/budget.model.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget/budget.model.ts deleted file mode 100644 index b8d61eb2..00000000 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget/budget.model.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface Budget { - id: string; - name: string; - taggingCriteria: string; - transferCriteria: string; - logbookCriteria: string; -} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts new file mode 100644 index 00000000..be23ec37 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts @@ -0,0 +1,23 @@ +// models.ts +export interface BudgetResponse { + id: string; + name?: string; + version?: string; + taggingCriteria?: string; + transferCriteria?: string; + logbookCriteria?: string; + } + + export interface CreateBudgetRequest { + name?: string; + } + + export interface IError { + message?: string; + metadata?: any; + reasons?: IError[]; + } + + export interface UpdateBudgetRequest extends BudgetResponse { + id: string; + } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html new file mode 100644 index 00000000..8a4744ef --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html @@ -0,0 +1,29 @@ +
+

Создание нового бюджета

+ + + + {{ errorMessage }} + + +
+
+ + + + + +
+
+
\ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.less new file mode 100644 index 00000000..e69de29b diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.spec.ts new file mode 100644 index 00000000..1a2cd1b6 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NewBudgetComponent } from './new-budget.component'; + +describe('NewBudgetComponent', () => { + let component: NewBudgetComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NewBudgetComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(NewBudgetComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts new file mode 100644 index 00000000..fafd3684 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts @@ -0,0 +1,56 @@ +import { Component } from '@angular/core'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { BudgetApiService } from '../budget-api.service'; +import { CreateBudgetRequest } from '../models'; +import { CommonModule } from '@angular/common'; +import { TuiButton, TuiNotification, TuiTextfield } from '@taiga-ui/core'; + +@Component({ + selector: 'app-new-budget', + templateUrl: './new-budget.component.html', + styleUrls: ['./new-budget.component.less'], + imports: [FormsModule, ReactiveFormsModule, CommonModule, TuiNotification, TuiTextfield, TuiButton], +}) +export class NewBudgetComponent { + nameGroup = new FormGroup({ + name: new FormControl('', [Validators.required]), + }); + + errorMessage: string | null = null; + + constructor(private budgetService: BudgetApiService) {} + + onSubmit() { + if (!this.nameGroup.controls.name.valid) { + this.errorMessage = 'Пожалуйста, введите название бюджета.'; + return; + } + + const request: CreateBudgetRequest = { + name: this.nameGroup.controls.name.value ?? '', + }; + + this.budgetService.createBudget(request).subscribe({ + next: (response) => { + alert(`Бюджет успешно создан: ${response.id}`); + this.resetForm(); + }, + error: (error) => { + this.handleError(error); + } + }); + } + + resetForm() { + this.nameGroup.controls.name.setValue(''); + this.errorMessage = null; + } + + handleError(error: any) { + if (error.status === 400 && error.error instanceof Array) { + this.errorMessage = error.error.map((err: any) => err.message).join(', '); + } else { + this.errorMessage = 'Произошла ошибка при создании бюджета. Попробуйте снова позже.'; + } + } +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj b/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj index 0df59e1b..c3b49323 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj @@ -25,6 +25,7 @@ +
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs index 016d61ea..cdcec5ce 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs @@ -5,6 +5,7 @@ using NVs.Budget.Controllers.Web; using NVs.Budget.Infrastructure.ExchangeRates.CBRF; using NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; +using NVs.Budget.Infrastructure.IO.Console; using NVs.Budget.Infrastructure.Persistence.EF; using NVs.Budget.Infrastructure.Persistence.EF.Context; using NVs.Budget.Utilities.Expressions; @@ -33,6 +34,7 @@ .AddTransient(p => p.GetRequiredService().CreateReckoner()) .AddApplicationUseCases() .AddSingleton(new Factory().CreateProvider()) + .AddConsoleIO().UseConsoleIO(builder.Configuration) .AddCors(opts => { var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? string.Empty; @@ -44,8 +46,8 @@ var app = builder.Build(); -app.UseYandexAuth(frontendUrl); -app.UseWebControllers(app.Environment.IsDevelopment()); +app.UseYandexAuth(frontendUrl) + .UseWebControllers(app.Environment.IsDevelopment()); app.UseCors(); app.MapGet("/", () => Results.Redirect(frontendUrl)); From 58d7376e1713a6531995424bf46d2e3a7cd3797c Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 4 Mar 2025 12:18:26 +0300 Subject: [PATCH 021/156] feat: budget selection works (mostly) --- .../budget-client/src/app/app.component.html | 2 +- .../budget-client/src/app/app.component.ts | 5 +- .../budget-client/src/app/app.routes.ts | 6 +- .../budget-detail.component.html | 1 + .../budget-detail.component.less | 0 .../budget-detail.component.spec.ts | 23 +++++++ .../budget-detail/budget-detail.component.ts | 21 +++++++ .../budget-selector.component.html | 23 ++++++- .../budget-selector.component.ts | 61 +++++++++++++++++-- .../src/app/index/index.component.html | 6 ++ .../src/app/index/index.component.less | 3 + .../src/app/index/index.component.spec.ts | 23 +++++++ .../src/app/index/index.component.ts | 20 ++++++ 13 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.spec.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html index 3b0b6d5d..598e4a47 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html @@ -2,7 +2,7 @@
The Budget. - + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts index 7757c687..c6f08755 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { TuiButton, TuiRoot } from "@taiga-ui/core"; +import { TuiRoot } from "@taiga-ui/core"; import { TuiBlockStatus, TuiNavigation } from "@taiga-ui/layout" import { Component, enableProdMode } from '@angular/core'; import { RouterLink, RouterOutlet } from '@angular/router'; @@ -7,6 +7,7 @@ import { environment } from '../environments/environment'; import { UserService } from "./auth/user.service"; import { CommonModule } from "@angular/common"; import { map } from "rxjs"; +import { BudgetSelectorComponent } from "./budget/budget-selector/budget-selector.component"; if (environment.production) { enableProdMode(); @@ -14,7 +15,7 @@ if (environment.production) { @Component({ selector: 'app-root', - imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, CommonModule, RouterLink], + imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, CommonModule, RouterLink, BudgetSelectorComponent], templateUrl: './app.component.html', styleUrl: './app.component.less' }) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts index 6bd9a3e4..02036a5f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts @@ -1,6 +1,10 @@ import { Routes } from '@angular/router'; import { NewBudgetComponent } from './budget/new-budget/new-budget.component'; +import { BudgetDetailComponent } from './budget/budget-detail/budget-detail.component'; +import { IndexComponent } from './index/index.component'; export const routes: Routes = [ - { path: 'budget/new', component: NewBudgetComponent } + { path: 'budget/new', component: NewBudgetComponent }, + { path: 'budget/:budgetId', component: BudgetDetailComponent }, + { path: '', component: IndexComponent } ]; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html new file mode 100644 index 00000000..11675bc7 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html @@ -0,0 +1 @@ +

BudgetId: {{budgetId$ | async}}

diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less new file mode 100644 index 00000000..e69de29b diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts new file mode 100644 index 00000000..e3a7bcb3 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BudgetDetailComponent } from './budget-detail.component'; + +describe('BudgetDetailComponent', () => { + let component: BudgetDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BudgetDetailComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(BudgetDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts new file mode 100644 index 00000000..1379988e --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts @@ -0,0 +1,21 @@ +import { AsyncPipe } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, map } from 'rxjs'; + +@Component({ + selector: 'app-budget-detail', + imports: [AsyncPipe], + templateUrl: './budget-detail.component.html', + styleUrl: './budget-detail.component.less' +}) + +export class BudgetDetailComponent implements OnInit { + budgetId$?: Observable; + + constructor(private route: ActivatedRoute) {} + + ngOnInit(): void { + this.budgetId$ = this.route.params.pipe(map(params => params['budgetId'])); + } +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html index f7d9d3dd..4064fcfc 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html @@ -1 +1,22 @@ -

budget-selector works!

+ + + + + + + {{budget.name}} + + + + + Добавить новый бюджет + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index 0c81d4c9..b5470f38 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -1,11 +1,62 @@ -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { BudgetApiService as BudgetApiService } from '../budget-api.service'; +import { BudgetResponse } from '../models'; +import { NavigationEnd, Router, RouterLink } from '@angular/router'; +import { TuiButton, TuiDataList, TuiDropdown, TuiDropdownOpen, TuiLink } from '@taiga-ui/core'; +import { TuiChevron, TuiTile } from '@taiga-ui/kit' +import { BehaviorSubject, filter, Observable, Subscription } from 'rxjs'; +import { AsyncPipe, CommonModule } from '@angular/common'; @Component({ selector: 'app-budget-selector', - imports: [], templateUrl: './budget-selector.component.html', - styleUrl: './budget-selector.component.less' + styleUrls: ['./budget-selector.component.less'], + imports: [CommonModule, TuiDataList, TuiDropdown, TuiDropdownOpen, TuiButton, TuiChevron, TuiLink, RouterLink, AsyncPipe] }) -export class BudgetSelectorComponent { +export class BudgetSelectorComponent implements OnInit, OnDestroy { + private budgetIdPattern = new RegExp("^/budget/([^/]*)$"); + private budgetSub: Subscription | undefined; + private routerSub: Subscription | undefined; + private selectedBudgetId: string | null = null; -} + budgets$: Observable | undefined; + budgetsSnapshot: BudgetResponse[] = []; + selectedBudget$: BehaviorSubject = new BehaviorSubject(undefined); + + constructor(private budgetApiService: BudgetApiService, private router: Router) {} + ngOnDestroy(): void { + console.log('destroying budget selector component'); + this.budgetSub?.unsubscribe(); + this.routerSub?.unsubscribe(); + } + + ngOnInit(): void { + this.setBudgetIdFrom(this.router.url); + + this.budgets$ = this.budgetApiService.getAllBudgets(); + this.routerSub = this.router.events + .pipe(filter(e => e instanceof NavigationEnd)) + .subscribe(n => { + this.setBudgetIdFrom(n.url); + this.updateSelectedBudget(); + }); + + this.budgetSub = this.budgets$.subscribe(budgets => { + this.budgetsSnapshot = budgets; + this.updateSelectedBudget(); + }); + } + + setBudgetIdFrom(url:string) { + this.selectedBudgetId = this.budgetIdPattern.exec(url)?.[1] ?? null; + } + + updateSelectedBudget() { + if (this.selectedBudgetId) { + const selectedBudget = this.budgetsSnapshot.find(budget => budget.id === this.selectedBudgetId); + this.selectedBudget$.next(selectedBudget); + } else { + this.selectedBudget$.next(undefined); + } + } +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.html new file mode 100644 index 00000000..4489d964 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.html @@ -0,0 +1,6 @@ +
+
    +
  1. Нажми "Войти", чтобы начать
  2. +
  3. Выбери бюджет
  4. +
+
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less new file mode 100644 index 00000000..f99f6189 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less @@ -0,0 +1,3 @@ +.strikethrough { + text-decoration: line-through; +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.spec.ts new file mode 100644 index 00000000..322e0c8a --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IndexComponent } from './index.component'; + +describe('IndexComponent', () => { + let component: IndexComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [IndexComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(IndexComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.ts new file mode 100644 index 00000000..e55767ce --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { TuiGroup } from '@taiga-ui/core'; +import { UserService } from '../auth/user.service'; +import { Observable, map } from 'rxjs'; +import { AsyncPipe, NgIf } from '@angular/common'; + +@Component({ + selector: 'app-index', + imports: [TuiGroup, AsyncPipe, NgIf], + templateUrl: './index.component.html', + styleUrl: './index.component.less' +}) +export class IndexComponent { + + isAuthenticated$: Observable; + + constructor(user: UserService) { + this.isAuthenticated$ = user.current$.pipe(map(user => user.isAuthenticated)); + } +} From ccdd3d97574d894105b5ac6df88b897da838589e Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 4 Mar 2025 14:45:17 +0300 Subject: [PATCH 022/156] fix: budget selector no longer shows "Nothing found" --- .../budget/budget-selector/budget-selector.component.html | 6 +++--- .../app/budget/budget-selector/budget-selector.component.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html index 4064fcfc..79874013 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html @@ -7,14 +7,14 @@ - + - + {{budget.name}} - + Добавить новый бюджет diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index b5470f38..1653d89d 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -2,8 +2,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { BudgetApiService as BudgetApiService } from '../budget-api.service'; import { BudgetResponse } from '../models'; import { NavigationEnd, Router, RouterLink } from '@angular/router'; -import { TuiButton, TuiDataList, TuiDropdown, TuiDropdownOpen, TuiLink } from '@taiga-ui/core'; -import { TuiChevron, TuiTile } from '@taiga-ui/kit' +import { TuiButton, TuiDataList, TuiDropdown, TuiLink } from '@taiga-ui/core'; +import { TuiChevron } from '@taiga-ui/kit' import { BehaviorSubject, filter, Observable, Subscription } from 'rxjs'; import { AsyncPipe, CommonModule } from '@angular/common'; @@ -11,7 +11,7 @@ import { AsyncPipe, CommonModule } from '@angular/common'; selector: 'app-budget-selector', templateUrl: './budget-selector.component.html', styleUrls: ['./budget-selector.component.less'], - imports: [CommonModule, TuiDataList, TuiDropdown, TuiDropdownOpen, TuiButton, TuiChevron, TuiLink, RouterLink, AsyncPipe] + imports: [CommonModule, TuiButton, TuiChevron, TuiDataList, TuiDropdown, RouterLink, AsyncPipe] }) export class BudgetSelectorComponent implements OnInit, OnDestroy { private budgetIdPattern = new RegExp("^/budget/([^/]*)$"); From d2a4805827ad2ea176892fb7d1fbdfae685248ae Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 4 Mar 2025 15:47:45 +0300 Subject: [PATCH 023/156] =?UTF-8?q?=D1=81hore:=20updated=20new=20budget=20?= =?UTF-8?q?form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../new-budget/new-budget.component.html | 59 +++++++++++-------- .../budget/new-budget/new-budget.component.ts | 12 ++-- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html index 8a4744ef..12c4fbbd 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html @@ -1,29 +1,38 @@ -
-

Создание нового бюджета

- - +
- {{ errorMessage }} + {{ errorMessage }} - - -
- - - - - -
-
-
\ No newline at end of file + + \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts index fafd3684..64048bb8 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts @@ -3,13 +3,17 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } import { BudgetApiService } from '../budget-api.service'; import { CreateBudgetRequest } from '../models'; import { CommonModule } from '@angular/common'; -import { TuiButton, TuiNotification, TuiTextfield } from '@taiga-ui/core'; +import { TuiButton, TuiError, TuiNotification, TuiTextfield } from '@taiga-ui/core'; +import { TuiFieldErrorPipe, tuiValidationErrorsProvider } from '@taiga-ui/kit'; +import { TuiForm } from '@taiga-ui/layout'; +import { Router } from '@angular/router'; @Component({ selector: 'app-new-budget', templateUrl: './new-budget.component.html', styleUrls: ['./new-budget.component.less'], - imports: [FormsModule, ReactiveFormsModule, CommonModule, TuiNotification, TuiTextfield, TuiButton], + imports: [FormsModule, ReactiveFormsModule, CommonModule, TuiNotification, TuiTextfield, TuiButton, TuiError, TuiFieldErrorPipe, TuiForm], + providers: [tuiValidationErrorsProvider({required: 'Пожалуйста, введите название бюджета'})] }) export class NewBudgetComponent { nameGroup = new FormGroup({ @@ -18,7 +22,7 @@ export class NewBudgetComponent { errorMessage: string | null = null; - constructor(private budgetService: BudgetApiService) {} + constructor(private budgetService: BudgetApiService, private router: Router) {} onSubmit() { if (!this.nameGroup.controls.name.valid) { @@ -32,8 +36,8 @@ export class NewBudgetComponent { this.budgetService.createBudget(request).subscribe({ next: (response) => { - alert(`Бюджет успешно создан: ${response.id}`); this.resetForm(); + this.router.navigate(['/budget', response.id]); }, error: (error) => { this.handleError(error); From a59d4cc47e56400f383d8b75e2ce62d84b0c3e60 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 4 Mar 2025 15:56:50 +0300 Subject: [PATCH 024/156] feat: refresh budgets on add/update --- .../src/app/budget/budget-api.service.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts index 854ad563..f0088adc 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, Observable, startWith, switchMap, tap } from 'rxjs'; import { BudgetResponse, CreateBudgetRequest, UpdateBudgetRequest } from './models'; // Импортируйте модели, соответствующие схеме OpenAPI import { environment } from '../../environments/environment'; @@ -9,6 +9,7 @@ import { environment } from '../../environments/environment'; }) export class BudgetApiService { private baseUrl = environment.apiUrl + '/api/v0.1'; // Ваш базовый URL для запросов + private refresh$ = new BehaviorSubject(false); constructor(private http: HttpClient) {} @@ -16,7 +17,11 @@ export class BudgetApiService { * Получение списка бюджетов */ getAllBudgets(): Observable { - return this.http.get(`${this.baseUrl}/budget`, { withCredentials: true }); + return this.refresh$.pipe( + startWith(undefined), // Инициируем первый запрос сразу + switchMap(() => + this.http.get(`${this.baseUrl}/budget`, { withCredentials: true }) + )); } /** @@ -24,14 +29,15 @@ export class BudgetApiService { */ createBudget(request: CreateBudgetRequest): Observable { const headers = new HttpHeaders().set('Content-Type', 'application/json'); - return this.http.post(`${this.baseUrl}/budget`, request, { headers, withCredentials: true }); + return this.http.post(`${this.baseUrl}/budget`, request, { headers, withCredentials: true }) + .pipe(tap(() => this.refresh$.next(true))); } /** * Обновление существующего бюджета */ updateBudget(id: string, request: UpdateBudgetRequest): Observable { - return this.http.put(`${this.baseUrl}/budget/${id}`, request, { withCredentials: true }); + return this.http.put(`${this.baseUrl}/budget/${id}`, request, { withCredentials: true }).pipe(tap(() => this.refresh$.next(true)));; } /** From af87717c3061fe5371d52b6e0caaed565db0dbde Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 18 May 2025 21:15:10 +0300 Subject: [PATCH 025/156] feat: BudgetConfiguration --- .../BudgetPatchingExtensionsShould.cs | 49 +++++++ .../GlobalUsings.cs | 1 + .../MappingProfileShould.cs | 60 ++++++++ .../NVs.Budget.Controllers.Web.Tests.csproj | 34 +++++ .../Controllers/BudgetController.cs | 82 +++-------- .../Controllers/BudgetResponse.cs | 10 -- .../Controllers/UpdateBudgetRequest.cs | 10 -- .../MappingProfile.cs | 65 ++++++--- .../Models/BudgetConfiguration.cs | 16 +++ .../Models/BudgetPatchingExtensions.cs | 135 ++++++++++++++++++ .../Models/LogbookCriteriaExpression.cs | 12 ++ .../Models/TransferCriterionExpression.cs | 9 ++ .../WebControllersExtensions.cs | 4 +- src/NVs.Budget.sln | 7 + 14 files changed, 389 insertions(+), 105 deletions(-) create mode 100644 src/Controllers/NVs.Budget.Controllers.Web.Tests/BudgetPatchingExtensionsShould.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web.Tests/GlobalUsings.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web.Tests/MappingProfileShould.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web.Tests/NVs.Budget.Controllers.Web.Tests.csproj delete mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetResponse.cs delete mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/UpdateBudgetRequest.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Models/BudgetConfiguration.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Models/BudgetPatchingExtensions.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Models/LogbookCriteriaExpression.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Models/TransferCriterionExpression.cs diff --git a/src/Controllers/NVs.Budget.Controllers.Web.Tests/BudgetPatchingExtensionsShould.cs b/src/Controllers/NVs.Budget.Controllers.Web.Tests/BudgetPatchingExtensionsShould.cs new file mode 100644 index 00000000..44431834 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web.Tests/BudgetPatchingExtensionsShould.cs @@ -0,0 +1,49 @@ +using AutoFixture; +using AutoMapper; +using FluentAssertions; +using FluentResults.Extensions.FluentAssertions; +using NVs.Budget.Application.Contracts.Criteria; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Controllers.Web.Models; +using NVs.Budget.Domain.Entities.Accounts; +using NVs.Budget.Utilities.Expressions; +using NVs.Budget.Utilities.Testing; + +namespace NVs.Budget.Controllers.Web.Tests; + +public class BudgetPatchingExtensionsShould +{ + private readonly Fixture _fixture = new(); + private readonly Mapper _mapper = new(new MapperConfiguration(cfg => cfg.AddProfile(new MappingProfile(ReadableExpressionsParser.Default)))); + + public BudgetPatchingExtensionsShould() + { + _fixture.Customizations.Add(new ReadableExpressionsBuilder()); + _fixture.Inject(LogbookCriteria.Universal); + } + + [Fact] + public void MapBudgetToBudgetConfiguration() + { + var expected = _fixture.Create(); + var configuration = _mapper.Map(expected); + + var empty = new TrackedBudget( + expected.Id, + string.Empty, + expected.Owners, + Enumerable.Empty(), + Enumerable.Empty(), + LogbookCriteria.Universal); + + var updated = empty.Patch(configuration, ReadableExpressionsParser.Default); + + updated.Should().BeSuccess(); + updated.Value.Name.Should().Be(configuration.Name); + updated.Value.Version.Should().Be(configuration.Version); + updated.Value.LogbookCriteria.Should().BeEquivalentTo(expected.LogbookCriteria, opts => opts.Excluding(x => x.Subcriteria)); + updated.Value.LogbookCriteria.Subcriteria.Should().BeNullOrEmpty(); + updated.Value.TaggingCriteria.Should().BeEquivalentTo(expected.TaggingCriteria); + updated.Value.TransferCriteria.Should().BeEquivalentTo(expected.TransferCriteria); + } +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web.Tests/GlobalUsings.cs b/src/Controllers/NVs.Budget.Controllers.Web.Tests/GlobalUsings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web.Tests/MappingProfileShould.cs b/src/Controllers/NVs.Budget.Controllers.Web.Tests/MappingProfileShould.cs new file mode 100644 index 00000000..2911ddb3 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web.Tests/MappingProfileShould.cs @@ -0,0 +1,60 @@ +using AutoFixture; +using AutoMapper; +using FluentAssertions; +using FluentResults.Extensions.FluentAssertions; +using NVs.Budget.Application.Contracts.Criteria; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Controllers.Web.Models; +using NVs.Budget.Utilities.Expressions; +using NVs.Budget.Utilities.Testing; + +namespace NVs.Budget.Controllers.Web.Tests; + +public class MappingProfileShould +{ + private readonly Fixture _fixture = new(); + private readonly Mapper _mapper = new(new MapperConfiguration(cfg => cfg.AddProfile(new MappingProfile(ReadableExpressionsParser.Default)))); + + public MappingProfileShould() + { + _fixture.Inject(LogbookCriteria.Universal); + _fixture.Customizations.Add(new ReadableExpressionsBuilder()); + } + + [Fact] + public void MapBudgetToBudgetConfiguration() + { + var expected = _fixture.Create(); + var groupedTags = expected.TaggingCriteria.GroupBy(x => x.Tag.ToString()); + var groupedTransfers = expected.TransferCriteria.GroupBy(x => x.Comment.ToString()) + .ToDictionary(x => x.Key, x => x.GroupBy(y => y.Accuracy) + ); + + var configuration = _mapper.Map(expected); + + configuration.Should().NotBeNull(); + configuration.Id.Should().Be(expected.Id); + configuration.Name.Should().Be(expected.Name); + configuration.Version.Should().Be(expected.Version); + configuration.Tags.Should().NotBeNull(); + configuration.Tags.Should().HaveCount(groupedTags.Count()); + foreach (var group in groupedTags) + { + configuration.Tags![group.Key].Should().BeEquivalentTo(group.Select(x => x.Condition.ToString())); + } + + configuration.Transfers.Should().HaveCount(groupedTransfers.Count()); + foreach (var group in groupedTransfers) + { + configuration.Transfers![group.Key].Should().HaveCount(group.Value.Count()); + foreach (var transfer in group.Value) + { + configuration.Transfers![group.Key].Single(g => g.Accuracy == transfer.Key).Criteria.Should() + .BeEquivalentTo(transfer.Select(x => x.Criterion.ToString())); + } + } + + configuration.Logbook.Should().NotBeNull(); + configuration.Logbook.Should().HaveCount(expected.LogbookCriteria.Subcriteria?.Count ?? 0); + } +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web.Tests/NVs.Budget.Controllers.Web.Tests.csproj b/src/Controllers/NVs.Budget.Controllers.Web.Tests/NVs.Budget.Controllers.Web.Tests.csproj new file mode 100644 index 00000000..2ed2ae36 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web.Tests/NVs.Budget.Controllers.Web.Tests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index 1011e131..3281605b 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -1,15 +1,12 @@ -using System.Net; using Asp.Versioning; using AutoMapper; using FluentResults; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using NVs.Budget.Application.Contracts.Criteria; -using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Application.Contracts.Services; -using NVs.Budget.Controllers.Web.Utils; -using NVs.Budget.Infrastructure.IO.Console.Input; +using NVs.Budget.Controllers.Web.Models; +using NVs.Budget.Utilities.Expressions; namespace NVs.Budget.Controllers.Web.Controllers; @@ -19,38 +16,36 @@ namespace NVs.Budget.Controllers.Web.Controllers; public class BudgetController( IBudgetManager manager, IMapper mapper, - ITaggingCriteriaReader tagsReader, - ITransferCriteriaReader transfersReader, - ILogbookCriteriaReader logbookReader + ReadableExpressionsParser parser ) : Controller { [HttpGet] - public async Task> GetBudgets(CancellationToken ct) + public async Task> GetBudgets(CancellationToken ct) { var budgets = await manager.GetOwnedBudgets(ct); - return mapper.Map>(budgets); + return mapper.Map>(budgets); } [HttpGet("{id:guid}")] - public async Task GetBudget(Guid id, CancellationToken ct) + public async Task GetBudget(Guid id, CancellationToken ct) { var budgets = await manager.GetOwnedBudgets(ct); - return mapper.Map(budgets.FirstOrDefault(b => b.Id == id)); + return mapper.Map(budgets.FirstOrDefault(b => b.Id == id)); } [HttpPost, - ProducesResponseType(StatusCodes.Status200OK), + ProducesResponseType(StatusCodes.Status200OK), ProducesResponseType>(StatusCodes.Status400BadRequest)] public async Task CreateBudget([FromBody] CreateBudgetRequest request, CancellationToken ct) { var result = await manager.Register(new(request.Name), ct); - return result.IsSuccess ? Ok(mapper.Map(result.Value)) : BadRequest(result.Errors); + return result.IsSuccess ? Ok(mapper.Map(result.Value)) : BadRequest(result.Errors); } [HttpPut, ProducesResponseType(StatusCodes.Status204NoContent), ProducesResponseType>(StatusCodes.Status400BadRequest)] - public async Task UpdateBudget([FromBody] UpdateBudgetRequest request, CancellationToken ct) + public async Task UpdateBudget([FromBody] BudgetConfiguration request, CancellationToken ct) { var budget = (await manager.GetOwnedBudgets(ct)).FirstOrDefault(b => b.Id == request.Id); if (budget == null) @@ -58,60 +53,15 @@ public async Task UpdateBudget([FromBody] UpdateBudgetRequest req return BadRequest("Budget not found"); } - var errors = new List(); - var tags = new List(); - var transfers = new List(); - LogbookCriteria? logbook = null; - - using (var reader = request.TaggingCriteria.AsStreamReader()) + var result = budget.Patch(request, parser); + + if (result.IsFailed) { - await foreach (var tagResult in tagsReader.ReadFrom(reader, ct)) - { - if (tagResult.IsSuccess) - { - tags.Add(tagResult.Value); - } - else - { - errors.AddRange(tagResult.Errors); - } - } + return BadRequest(result.Errors); } - using (var reader = request.TransferCriteria.AsStreamReader()) - { - await foreach (var transferResult in transfersReader.ReadFrom(reader, ct)) - { - if (transferResult.IsSuccess) - { - transfers.Add(transferResult.Value); - } - else - { - errors.AddRange(transferResult.Errors); - } - } - } - - using (var reader = request.LogbookCriteria.AsStreamReader()) - { - var logbookResult = await logbookReader.ReadFrom(reader, ct); - if (logbookResult.IsSuccess) - { - logbook = logbookResult.Value; - } - else - { - errors.AddRange(logbookResult.Errors); - } - } - - if (errors.Any()) - { - return BadRequest(errors); - } + result = await manager.Update(result.Value, ct); - var result = await manager.Update(new(request.Id, request.Name, budget.Owners, tags, transfers, logbook!), ct); return result.IsSuccess ? NoContent() : BadRequest(result.Errors); } -} +} \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetResponse.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetResponse.cs deleted file mode 100644 index d327224e..00000000 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NVs.Budget.Controllers.Web.Controllers; - -public record BudgetResponse( - Guid Id, - string Name, - string Version, - string TaggingCriteria, - string TransferCriteria, - string LogbookCriteria - ); diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/UpdateBudgetRequest.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/UpdateBudgetRequest.cs deleted file mode 100644 index 6ca3c0f6..00000000 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/UpdateBudgetRequest.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NVs.Budget.Controllers.Web.Controllers; - -public record UpdateBudgetRequest( - Guid Id, - string Name, - string Version, - string TaggingCriteria, - string TransferCriteria, - string LogbookCriteria -); \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs b/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs index 221f08ad..2940f7ae 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs @@ -1,7 +1,7 @@ using AutoMapper; +using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Application.Contracts.Entities.Budgeting; -using NVs.Budget.Controllers.Web.Controllers; -using NVs.Budget.Domain.Entities.Operations; +using NVs.Budget.Controllers.Web.Models; using NVs.Budget.Utilities.Expressions; namespace NVs.Budget.Controllers.Web; @@ -10,22 +10,51 @@ internal class MappingProfile : Profile { public MappingProfile(ReadableExpressionsParser parser) { - CreateMap>, string>().ConstructUsing(r => r.ToString()); - CreateMap>>().ConstructUsing( - r => parser.ParseUnaryConversion(r).Value - ); - CreateMap>, string>().ConstructUsing(r => r.ToString()); - CreateMap>>().ConstructUsing( - r => parser.ParseUnaryPredicate(r).Value - ); - CreateMap>, string>().ConstructUsing(r => r.ToString()); - CreateMap>>().ConstructUsing(r => parser.ParseUnaryPredicate(r).Value); - CreateMap>, string>().ConstructUsing(r => r.ToString()); - CreateMap>>().ConstructUsing(r => parser.ParseUnaryConversion(r).Value); + CreateMap, IDictionary>>().ConvertUsing(); + CreateMap, IDictionary>>().ConvertUsing(); + CreateMap>().ConvertUsing(); + CreateMap() + .ForMember(dest => dest.Tags, opt => opt.MapFrom(src => src.TaggingCriteria)) + .ForMember(dest => dest.Transfers, opt => opt.MapFrom(src => src.TransferCriteria)) + .ForMember(dest => dest.Logbook, opt => opt.MapFrom(src => src.LogbookCriteria)); + } +} - CreateMap>, string>().ConstructUsing(r => r.ToString()); - CreateMap>>().ConstructUsing(r => parser.ParseBinaryPredicate(r).Value); +internal class Converter : ITypeConverter, IDictionary>>, + ITypeConverter, IDictionary>>, + ITypeConverter> +{ + public IDictionary> Convert(IEnumerable source, IDictionary> destination, ResolutionContext context) + { + return source.GroupBy(x => x.Tag.ToString()).ToDictionary(x => x.Key, x => x.Select(y => y.Condition.ToString())); + } - CreateMap(); + public IDictionary> Convert(IEnumerable source, IDictionary> _, ResolutionContext __) + { + return source.GroupBy(x => x.Comment).ToDictionary( + g => g.Key, + g => g.GroupBy(g => g.Accuracy).Select(g => new TransferCriterionExpression + { + Accuracy = g.Key, + Criteria = g.Select(x => x.Criterion.ToString()) + })); + } + + public IDictionary Convert(LogbookCriteria source, IDictionary _, ResolutionContext __) + { + return source.Subcriteria?.ToDictionary(s => s.Description, ConvertCriteria) ?? new(); + } + + private LogbookCriteriaExpression ConvertCriteria(LogbookCriteria criteria) + { + var subcriteria = criteria.Subcriteria?.ToDictionary(s => s.Description, ConvertCriteria); + return new LogbookCriteriaExpression + { + Subcriteria = subcriteria, + Type = criteria.Type, + Tags = criteria.Tags?.Select(t => t.ToString()), + Substitution = criteria.Substitution?.ToString(), + Criteria = criteria.Criteria?.ToString() + }; } -} \ No newline at end of file +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/BudgetConfiguration.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/BudgetConfiguration.cs new file mode 100644 index 00000000..5539694c --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/BudgetConfiguration.cs @@ -0,0 +1,16 @@ +namespace NVs.Budget.Controllers.Web.Models; + +public class BudgetConfiguration +{ + public Guid Id { get; init; } + + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public IDictionary>? Tags { get; set; } + + public IDictionary>? Transfers { get; set; } + + public IDictionary? Logbook { get; set; } +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/BudgetPatchingExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/BudgetPatchingExtensions.cs new file mode 100644 index 00000000..f45a8f23 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/BudgetPatchingExtensions.cs @@ -0,0 +1,135 @@ +using FluentResults; +using NVs.Budget.Application.Contracts.Criteria; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Domain.Entities.Operations; +using NVs.Budget.Domain.ValueObjects; +using NVs.Budget.Domain.ValueObjects.Criteria; +using NVs.Budget.Utilities.Expressions; + +namespace NVs.Budget.Controllers.Web.Models +{ + public static class BudgetPatchingExtensions + { + public static Result Patch(this TrackedBudget budget, BudgetConfiguration patch, ReadableExpressionsParser parser) + { + var tags = ParseTaggingCriteria(patch.Tags, parser).ToList(); + if (tags.Any(t => t.IsFailed)) + { + return Result.Fail(tags.SelectMany(t => t.Errors).ToList()); + } + + var transfers = ParseTransferCriteria(patch.Transfers, parser).ToList(); + if (transfers.Any(t => t.IsFailed)) + { + return Result.Fail(transfers.SelectMany(t => t.Errors).ToList()); + } + + var logbook = ParseLogbookCriteria(patch.Logbook, parser); + if (logbook.IsFailed) + { + return Result.Fail(logbook.Errors); + } + + return new TrackedBudget( + budget.Id, + patch.Name, + budget.Owners, + tags.Select(t => t.Value), + transfers.Select(t => t.Value), + logbook.Value + ) + { + Version = patch.Version + }; + } + + private static IEnumerable> ParseTransferCriteria(IDictionary>? transfers, ReadableExpressionsParser parser) + { + foreach (var (comment, criterions) in transfers ?? Enumerable.Empty>>()) + { + foreach (var criterion in criterions) + { + foreach (var condition in criterion.Criteria ?? Enumerable.Empty()) + { + var conditionExpression = parser.ParseBinaryPredicate(condition); + if (conditionExpression.IsFailed) + { + yield return conditionExpression.ToResult(); + } + + yield return new TransferCriterion(criterion.Accuracy, comment, conditionExpression.Value); + } + } + } + } + + private static IEnumerable> ParseTaggingCriteria(IDictionary>? tags, ReadableExpressionsParser parser) + { + foreach (var (tag, conditiosns) in tags ?? Enumerable.Empty>>()) + { + var tagExpression = parser.ParseUnaryConversion(tag); + if (tagExpression.IsFailed) + { + yield return tagExpression.ToResult(); + } + foreach (var condition in conditiosns) + { + var conditionExpression = parser.ParseUnaryPredicate(condition); + if (conditionExpression.IsFailed) + { + yield return conditionExpression.ToResult(); + } + + yield return new TaggingCriterion(tagExpression.Value, conditionExpression.Value); + } + } + } + + private static Result ParseLogbookCriteria(IDictionary? logbook, ReadableExpressionsParser parser) + { + if (logbook is null) + { + return Result.Ok(LogbookCriteria.Universal); + } + + var subcriteria = new List(); + + foreach (var (description, criterions) in logbook) + { + var parsedCriteria = ParseLogbookCriteria(description, criterions, parser); + if (parsedCriteria.IsFailed) + { + return Result.Fail(new Error($"Failed to parse logbook criteria for {description}").CausedBy(parsedCriteria.Errors)); + } + + subcriteria.Add(parsedCriteria.Value); + } + + return Result.Ok(new LogbookCriteria(string.Empty, subcriteria.AsReadOnly(), null, null, null, null, true)); + } + + private static Result ParseLogbookCriteria(string description, LogbookCriteriaExpression criterions, ReadableExpressionsParser parser) + { + var subcriteria = new List(); + if (criterions.Subcriteria is not null) + foreach (var (subDescr, subCriterion) in criterions.Subcriteria) + { + var parsedCriteria = ParseLogbookCriteria(subDescr, subCriterion, parser); + if (parsedCriteria.IsFailed) + { + return parsedCriteria; + } + + subcriteria.Add(parsedCriteria.Value); + } + + return criterions switch + { + { Criteria: not null } => parser.ParseUnaryPredicate(criterions.Criteria).Map(c => new LogbookCriteria(description, subcriteria.AsReadOnly(), null, null, null, c, false)), + { Substitution: not null } => parser.ParseUnaryConversion(criterions.Substitution).Map(s => new LogbookCriteria(description, subcriteria.AsReadOnly(), null, null, s, null, false)), + { Tags: not null } => new LogbookCriteria(description, subcriteria.AsReadOnly(), criterions.Type ?? TagBasedCriterionType.OneOf, criterions.Tags.Select(t => new Tag(t)).ToList().AsReadOnly(), null, null, false), + _ => LogbookCriteria.Universal + }; + } + } +} \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/LogbookCriteriaExpression.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/LogbookCriteriaExpression.cs new file mode 100644 index 00000000..9dc1af90 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/LogbookCriteriaExpression.cs @@ -0,0 +1,12 @@ +using NVs.Budget.Domain.ValueObjects.Criteria; + +namespace NVs.Budget.Controllers.Web.Models; + +public class LogbookCriteriaExpression +{ + public IDictionary? Subcriteria { get; set; } + public TagBasedCriterionType? Type { get; set; } + public IEnumerable? Tags { get; set; } + public string? Substitution { get; set; } + public string? Criteria { get; set; } +} \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/TransferCriterionExpression.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/TransferCriterionExpression.cs new file mode 100644 index 00000000..dad272b7 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/TransferCriterionExpression.cs @@ -0,0 +1,9 @@ +using NVs.Budget.Application.Contracts.Entities.Budgeting; + +namespace NVs.Budget.Controllers.Web.Models; + +public class TransferCriterionExpression +{ + public DetectionAccuracy Accuracy { get; set; } + public IEnumerable? Criteria { get; set; } +} \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs index b7632992..307cbc4a 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs @@ -1,13 +1,15 @@ using System.Reflection; +using System.Runtime.CompilerServices; using Asp.Versioning; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; using NVs.Budget.Utilities.Expressions; using Swashbuckle.AspNetCore.SwaggerGen; +[assembly: InternalsVisibleTo("NVs.Budget.Controllers.Web.Tests")] + namespace NVs.Budget.Controllers.Web; public static class WebControllersExtensions diff --git a/src/NVs.Budget.sln b/src/NVs.Budget.sln index a11ed025..50559434 100644 --- a/src/NVs.Budget.sln +++ b/src/NVs.Budget.sln @@ -81,6 +81,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Controllers.Web" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Hosts.Web.Client", "Hosts\NVs.Budget.Hosts.Web.Client\NVs.Budget.Hosts.Web.Client.csproj", "{8F054687-62ED-4A70-88FC-5D868673758F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NVs.Budget.Controllers.Web.Tests", "Controllers\NVs.Budget.Controllers.Web.Tests\NVs.Budget.Controllers.Web.Tests.csproj", "{28534653-368D-47F5-8C29-44D7D92C9AAA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -119,6 +121,7 @@ Global {6E2847D7-0B1C-4609-BB15-0AEA262B1123} = {BBC78D28-4FE0-4044-8540-5E026D8BACB9} {EB175F71-CAA9-496A-839A-6597EC28CE7D} = {8FBC3B88-0C56-4282-BBF3-607292E95F91} {8F054687-62ED-4A70-88FC-5D868673758F} = {ABCBF4A8-F237-4243-9A07-27D6B361CCCB} + {28534653-368D-47F5-8C29-44D7D92C9AAA} = {8FBC3B88-0C56-4282-BBF3-607292E95F91} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A757A6B7-3317-493A-B9CB-DA97CCCA6A7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -245,5 +248,9 @@ Global {8F054687-62ED-4A70-88FC-5D868673758F}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F054687-62ED-4A70-88FC-5D868673758F}.Release|Any CPU.ActiveCfg = Release|Any CPU {8F054687-62ED-4A70-88FC-5D868673758F}.Release|Any CPU.Build.0 = Release|Any CPU + {28534653-368D-47F5-8C29-44D7D92C9AAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28534653-368D-47F5-8C29-44D7D92C9AAA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28534653-368D-47F5-8C29-44D7D92C9AAA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28534653-368D-47F5-8C29-44D7D92C9AAA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal From 5821b6b44680a9637bf15d3eee77084ccab6b20d Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 18 May 2025 22:31:38 +0300 Subject: [PATCH 026/156] feat: download budget yaml --- .../Controllers/BudgetController.cs | 63 ++++++++++++++++++- .../Formatters/YamlOutputFormatter.cs | 31 +++++++++ .../NVs.Budget.Controllers.Web.csproj | 1 + .../WebControllersExtensions.cs | 19 +++++- .../src/app/budget/budget-api.service.ts | 2 +- .../budget-detail.component.html | 7 +++ .../budget-detail/budget-detail.component.ts | 7 ++- .../Properties/launchSettings.json | 8 +-- 8 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlOutputFormatter.cs diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index 3281605b..cb64d04c 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -7,6 +7,7 @@ using NVs.Budget.Application.Contracts.Services; using NVs.Budget.Controllers.Web.Models; using NVs.Budget.Utilities.Expressions; +using YamlDotNet.Serialization; namespace NVs.Budget.Controllers.Web.Controllers; @@ -16,7 +17,8 @@ namespace NVs.Budget.Controllers.Web.Controllers; public class BudgetController( IBudgetManager manager, IMapper mapper, - ReadableExpressionsParser parser + ReadableExpressionsParser parser, + IDeserializer yamlDeserializer ) : Controller { [HttpGet] @@ -33,6 +35,14 @@ public async Task> GetBudgets(Cancellat return mapper.Map(budgets.FirstOrDefault(b => b.Id == id)); } + [HttpGet("{id:guid}.yaml")] + [Produces("application/yaml")] + public async Task GetBudgetConfiguration(Guid id, CancellationToken ct) + { + var budgets = await manager.GetOwnedBudgets(ct); + return mapper.Map(budgets.FirstOrDefault(b => b.Id == id)); + } + [HttpPost, ProducesResponseType(StatusCodes.Status200OK), ProducesResponseType>(StatusCodes.Status400BadRequest)] @@ -54,7 +64,7 @@ public async Task UpdateBudget([FromBody] BudgetConfiguration req } var result = budget.Patch(request, parser); - + if (result.IsFailed) { return BadRequest(result.Errors); @@ -64,4 +74,53 @@ public async Task UpdateBudget([FromBody] BudgetConfiguration req return result.IsSuccess ? NoContent() : BadRequest(result.Errors); } + + [HttpPut(":upload")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + public async Task UpdateBudgetFromYaml( + IFormFile file, + CancellationToken ct) + { + // Validate file + if (file == null || file.Length == 0) + { + return BadRequest("No file uploaded"); + } + + if (!file.FileName.EndsWith(".yaml") && !file.FileName.EndsWith(".yml")) + { + return BadRequest("Only YAML files are supported"); + } + + try + { + // Read and parse YAML + using var streamReader = new StreamReader(file.OpenReadStream()); + var yamlContent = await streamReader.ReadToEndAsync(); + var request = yamlDeserializer.Deserialize(yamlContent); + + // Find existing budget + var budget = (await manager.GetOwnedBudgets(ct)).FirstOrDefault(b => b.Id == request.Id); + if (budget == null) + { + return BadRequest("Budget not found"); + } + + // Apply changes + var result = budget.Patch(request, parser); + if (result.IsFailed) + { + return BadRequest(result.Errors); + } + + // Update budget + result = await manager.Update(result.Value, ct); + return result.IsSuccess ? NoContent() : BadRequest(result.Errors); + } + catch (Exception ex) + { + return BadRequest($"Invalid YAML format: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlOutputFormatter.cs b/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlOutputFormatter.cs new file mode 100644 index 00000000..ed438fa5 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlOutputFormatter.cs @@ -0,0 +1,31 @@ +using System; +using System.Text; +using Microsoft.AspNetCore.Mvc.Formatters; +using YamlDotNet.Serialization; + +namespace NVs.Budget.Controllers.Web.Formatters; + +internal class YamlOutputFormatter : TextOutputFormatter +{ + private readonly ISerializer _serializer; + + public YamlOutputFormatter(ISerializer serializer) + { + _serializer = serializer; + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + SupportedMediaTypes.Add("application/yaml"); + SupportedMediaTypes.Add("text/yaml"); + } + + public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + var response = context.HttpContext.Response; + using (var writer = context.WriterFactory(response.Body, selectedEncoding)) + { + _serializer.Serialize(writer, context.Object); + await writer.FlushAsync(); + } + } +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj b/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj index dd16e92f..97a9d58b 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj +++ b/src/Controllers/NVs.Budget.Controllers.Web/NVs.Budget.Controllers.Web.csproj @@ -13,6 +13,7 @@ + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs index 307cbc4a..18666997 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs @@ -5,8 +5,11 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi.Models; +using NVs.Budget.Controllers.Web.Formatters; using NVs.Budget.Utilities.Expressions; using Swashbuckle.AspNetCore.SwaggerGen; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; [assembly: InternalsVisibleTo("NVs.Budget.Controllers.Web.Tests")] @@ -16,8 +19,12 @@ public static class WebControllersExtensions { public static IServiceCollection AddWebControllers(this IServiceCollection services, ReadableExpressionsParser parser) { - services.AddAutoMapper(m => m.AddProfile(new MappingProfile(parser))); + services.AddSingleton(new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build()); + services.AddAutoMapper(m => m.AddProfile(new MappingProfile(parser))); + services.AddSingleton(); services.AddEndpointsApiExplorer(); services.AddSwaggerGen(o => { @@ -50,7 +57,15 @@ public static IServiceCollection AddWebControllers(this IServiceCollection servi var assembly = typeof(WebControllersExtensions).Assembly; var part = new AssemblyPart(assembly); services - .AddControllersWithViews() + .AddControllersWithViews(opts => + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + opts.OutputFormatters.Add(new YamlOutputFormatter(serializer)); + opts.FormatterMappings.SetMediaTypeMappingForFormat("yaml", "application/yaml"); + }) .ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); services.AddApiVersioning(); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts index f0088adc..d3d181c2 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts @@ -8,7 +8,7 @@ import { environment } from '../../environments/environment'; providedIn: 'root' }) export class BudgetApiService { - private baseUrl = environment.apiUrl + '/api/v0.1'; // Ваш базовый URL для запросов + public readonly baseUrl = environment.apiUrl + '/api/v0.1'; // Ваш базовый URL для запросов private refresh$ = new BehaviorSubject(false); constructor(private http: HttpClient) {} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html index 11675bc7..e9432985 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html @@ -1 +1,8 @@

BudgetId: {{budgetId$ | async}}

+ + Download as YAML + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts index 1379988e..49c64653 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts @@ -2,6 +2,7 @@ import { AsyncPipe } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable, map } from 'rxjs'; +import { BudgetApiService } from '../budget-api.service'; @Component({ selector: 'app-budget-detail', @@ -13,7 +14,11 @@ import { Observable, map } from 'rxjs'; export class BudgetDetailComponent implements OnInit { budgetId$?: Observable; - constructor(private route: ActivatedRoute) {} + constructor(private route: ActivatedRoute, apiService: BudgetApiService) { + this.baseUrl = apiService.baseUrl; + } + + public readonly baseUrl: string; ngOnInit(): void { this.budgetId$ = this.route.params.pipe(map(params => params['budgetId'])); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json b/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json index 04559916..964e3d00 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json @@ -1,20 +1,20 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "http": { + "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "http://localhost:5153", + "applicationUrl": "https://localhost:7237;http://localhost:5153", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, - "https": { + "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:7237;http://localhost:5153", + "applicationUrl": "http://localhost:5153", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From 1896e2e8b910deae83d0e1c0979b014838e15af2 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Wed, 21 May 2025 22:48:47 +0300 Subject: [PATCH 027/156] feat: configuration download and upload buttons --- .../src/app/budget/budget-api.service.ts | 19 ++++++++ .../budget-selector.component.html | 22 +++++++++ .../budget-selector.component.less | 14 ++++++ .../budget-selector.component.ts | 48 ++++++++++++++++++- src/Hosts/docker-compose.yml | 2 +- 5 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts index d3d181c2..34a6b8d3 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts @@ -46,4 +46,23 @@ export class BudgetApiService { getBudgetById(id: string): Observable { return this.http.get(`${this.baseUrl}/budget/${id}`, { withCredentials: true }); } + + /** + * Download budget configuration as YAML + */ + downloadBudgetYaml(id: string): Observable { + return this.http.get(`${this.baseUrl}/budget/${id}.yaml`, { + responseType: 'blob', + withCredentials: true + }); + } + + /** + * Upload budget configuration from YAML file + */ + uploadBudgetYaml(file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + return this.http.put(`${this.baseUrl}/budget/:upload`, formData, { withCredentials: true }); + } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html index 79874013..f06b5823 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html @@ -6,6 +6,28 @@ {{(selectedBudget$ | async)?.name ?? "Выбери бюджет из списка..."}} + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less index e69de29b..f122f69d 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less @@ -0,0 +1,14 @@ +:host { + display: flex; + gap: 8px; + align-items: center; +} + +.download-button, +.upload-button { + padding: 0 8px; + min-width: 40px; + height: 40px; + font-size: 20px; + line-height: 1; +} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index 1653d89d..642663e4 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { BudgetApiService as BudgetApiService } from '../budget-api.service'; import { BudgetResponse } from '../models'; import { NavigationEnd, Router, RouterLink } from '@angular/router'; -import { TuiButton, TuiDataList, TuiDropdown, TuiLink } from '@taiga-ui/core'; +import { TuiButton, TuiDataList, TuiDropdown, TuiLink, TuiDialogService } from '@taiga-ui/core'; import { TuiChevron } from '@taiga-ui/kit' import { BehaviorSubject, filter, Observable, Subscription } from 'rxjs'; import { AsyncPipe, CommonModule } from '@angular/common'; @@ -23,7 +23,11 @@ export class BudgetSelectorComponent implements OnInit, OnDestroy { budgetsSnapshot: BudgetResponse[] = []; selectedBudget$: BehaviorSubject = new BehaviorSubject(undefined); - constructor(private budgetApiService: BudgetApiService, private router: Router) {} + constructor( + private budgetApiService: BudgetApiService, + private router: Router, + private dialogService: TuiDialogService + ) {} ngOnDestroy(): void { console.log('destroying budget selector component'); this.budgetSub?.unsubscribe(); @@ -59,4 +63,44 @@ export class BudgetSelectorComponent implements OnInit, OnDestroy { this.selectedBudget$.next(undefined); } } + + downloadBudget() { + if (this.selectedBudgetId) { + this.budgetApiService.downloadBudgetYaml(this.selectedBudgetId).subscribe(blob => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `budget-${this.selectedBudgetId}.yaml`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }); + } + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + this.budgetApiService.uploadBudgetYaml(file).subscribe({ + next: () => { + // Refresh the budget list after successful upload + this.budgets$ = this.budgetApiService.getAllBudgets(); + }, + error: (error) => { + let errorMessage = 'Произошла ошибка при загрузке файла.'; + if (error.status === 400 && error.error instanceof Array) { + errorMessage = error.error.map((err: any) => err.message).join(', '); + } + this.dialogService.open(errorMessage, { + label: 'Ошибка загрузки', + size: 's', + closeable: true, + dismissible: true, + }).subscribe(); + } + }); + } + } } \ No newline at end of file diff --git a/src/Hosts/docker-compose.yml b/src/Hosts/docker-compose.yml index 62325bcb..d1348453 100644 --- a/src/Hosts/docker-compose.yml +++ b/src/Hosts/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.9" services: postgres: - image: postgres + image: postgres:13 environment: POSTGRES_DB: "budgetdb" POSTGRES_USER: "postgres" From ae3840db4a9eebe06ef2a41e6a65cd77527de7a3 Mon Sep 17 00:00:00 2001 From: Nikita Senkov Date: Fri, 23 May 2025 22:43:03 +0300 Subject: [PATCH 028/156] fix: display import errors --- .../NVs.Budget.Controllers.Web/Controllers/BudgetController.cs | 2 +- .../src/app/budget/budget-selector/budget-selector.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index cb64d04c..71ac8f7e 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -120,7 +120,7 @@ public async Task UpdateBudgetFromYaml( } catch (Exception ex) { - return BadRequest($"Invalid YAML format: {ex.Message}"); + return BadRequest(Result.Fail(ex.Message).Errors); } } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index 642663e4..b92fe649 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -95,7 +95,7 @@ export class BudgetSelectorComponent implements OnInit, OnDestroy { } this.dialogService.open(errorMessage, { label: 'Ошибка загрузки', - size: 's', + size: 'm', closeable: true, dismissible: true, }).subscribe(); From fa714ddf56ee775fe98b9a2833e66ef39384dc4d Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 1 Jun 2025 11:07:49 +0300 Subject: [PATCH 029/156] chore(client): bumped readme --- .../budget-client/README.md | 51 ++----------------- 1 file changed, 4 insertions(+), 47 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/README.md b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/README.md index b3bf0f0b..e17723e6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/README.md +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/README.md @@ -1,8 +1,11 @@ # BudgetClient +Web app for Budget - online expence tracking and analytics service. + This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.0. ## Development server +In order to work with API server properly, app must be served via HTTPS. Current dev configuration expects to have `./certificates/dev/` folder with `nginx.crt` and `nginx.key` files. Please create your own certificate files prior to run app. To start a local development server, run: @@ -10,50 +13,4 @@ To start a local development server, run: ng serve ``` -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. - -## Code scaffolding - -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: - -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: - -```bash -ng generate --help -``` - -## Building - -To build the project run: - -```bash -ng build -``` - -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -## Running unit tests - -To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command: - -```bash -ng test -``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: - -```bash -ng e2e -``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. +Once the server is running, open your browser and navigate to `https://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. From a1e5c9df03008b2bbacf5b46432600bfc95ff1af Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 1 Jun 2025 12:18:09 +0300 Subject: [PATCH 030/156] feat: CsvReadingOptions handling --- .../Controllers/BudgetController.cs | 69 ++++++++++++++++++- .../src/app/budget/budget-api.service.ts | 19 +++++ .../budget-selector.component.html | 22 ++++++ .../budget-selector.component.ts | 44 ++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index 71ac8f7e..eb9e1fbf 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -6,6 +6,8 @@ using Microsoft.AspNetCore.Mvc; using NVs.Budget.Application.Contracts.Services; using NVs.Budget.Controllers.Web.Models; +using NVs.Budget.Infrastructure.IO.Console.Input; +using NVs.Budget.Infrastructure.IO.Console.Options; using NVs.Budget.Utilities.Expressions; using YamlDotNet.Serialization; @@ -18,7 +20,9 @@ public class BudgetController( IBudgetManager manager, IMapper mapper, ReadableExpressionsParser parser, - IDeserializer yamlDeserializer + IDeserializer yamlDeserializer, + ICsvReadingOptionsReader csvOptionsReader, + IBudgetSpecificSettingsRepository settingsRepository ) : Controller { [HttpGet] @@ -123,4 +127,67 @@ public async Task UpdateBudgetFromYaml( return BadRequest(Result.Fail(ex.Message).Errors); } } + + [HttpPut("{id:guid}/csv-options")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType>(StatusCodes.Status400BadRequest)] + public async Task UpdateCsvReadingOptions( + Guid id, + IFormFile file, + CancellationToken ct) + { + // Validate file + if (file == null || file.Length == 0) + { + return BadRequest("No file uploaded"); + } + + if (!file.FileName.EndsWith(".yaml") && !file.FileName.EndsWith(".yml")) + { + return BadRequest("Only YAML files are supported"); + } + + try + { + // Find existing budget + var budget = (await manager.GetOwnedBudgets(ct)).FirstOrDefault(b => b.Id == id); + if (budget == null) + { + return BadRequest("Budget not found"); + } + + // Read and parse options + using var streamReader = new StreamReader(file.OpenReadStream()); + var result = await csvOptionsReader.ReadFrom(streamReader, ct); + if (result.IsFailed) + { + return BadRequest(result.Errors); + } + + // Update options + var updateResult = await settingsRepository.UpdateReadingOptionsFor(budget, result.Value, ct); + return updateResult.IsSuccess ? NoContent() : BadRequest(updateResult.Errors); + } + catch (Exception ex) + { + return BadRequest(Result.Fail(ex.Message).Errors); + } + } + + [HttpGet("{id:guid}/csv-options.yaml")] + [Produces("application/yaml")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetCsvReadingOptions(Guid id, CancellationToken ct) + { + // Find existing budget + var budget = (await manager.GetOwnedBudgets(ct)).FirstOrDefault(b => b.Id == id); + if (budget == null) + { + return NotFound("Budget not found"); + } + + // Get and return options - will be automatically serialized to YAML + return await settingsRepository.GetReadingOptionsFor(budget, ct); + } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts index 34a6b8d3..02858e51 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts @@ -65,4 +65,23 @@ export class BudgetApiService { formData.append('file', file); return this.http.put(`${this.baseUrl}/budget/:upload`, formData, { withCredentials: true }); } + + /** + * Download CSV reading options as YAML + */ + downloadCsvOptionsYaml(id: string): Observable { + return this.http.get(`${this.baseUrl}/budget/${id}/csv-options.yaml`, { + responseType: 'blob', + withCredentials: true + }); + } + + /** + * Upload CSV reading options from YAML file + */ + uploadCsvOptionsYaml(id: string, file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + return this.http.put(`${this.baseUrl}/budget/${id}/csv-options`, formData, { withCredentials: true }); + } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html index f06b5823..7137e922 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html @@ -28,6 +28,28 @@ (change)="onFileSelected($event)"> + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index b92fe649..c6a5d0de 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -103,4 +103,48 @@ export class BudgetSelectorComponent implements OnInit, OnDestroy { }); } } + + downloadCsvOptions() { + if (this.selectedBudgetId) { + this.budgetApiService.downloadCsvOptionsYaml(this.selectedBudgetId).subscribe(blob => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `csv-options-${this.selectedBudgetId}.yaml`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }); + } + } + + onCsvOptionsFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0 && this.selectedBudgetId) { + const file = input.files[0]; + this.budgetApiService.uploadCsvOptionsYaml(this.selectedBudgetId, file).subscribe({ + next: () => { + this.dialogService.open('CSV options updated successfully', { + label: 'Success', + size: 'm', + closeable: true, + dismissible: true, + }).subscribe(); + }, + error: (error) => { + let errorMessage = 'Error uploading CSV options.'; + if (error.status === 400 && error.error instanceof Array) { + errorMessage = error.error.map((err: any) => err.message).join(', '); + } + this.dialogService.open(errorMessage, { + label: 'Upload Error', + size: 'm', + closeable: true, + dismissible: true, + }).subscribe(); + } + }); + } + } } \ No newline at end of file From 216aa77b60326440de173d87a31c5c7ab06be19b Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 1 Jun 2025 14:35:26 +0300 Subject: [PATCH 031/156] chore: fixed postgres version for local debug --- src/Hosts/docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Hosts/docker-compose.yml b/src/Hosts/docker-compose.yml index d1348453..170d3981 100644 --- a/src/Hosts/docker-compose.yml +++ b/src/Hosts/docker-compose.yml @@ -1,7 +1,6 @@ -version: "3.9" services: postgres: - image: postgres:13 + image: postgres:17 environment: POSTGRES_DB: "budgetdb" POSTGRES_USER: "postgres" From 741afc4494c02e8e2b8dac840bac9a1b9ea84244 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 1 Jun 2025 19:24:14 +0300 Subject: [PATCH 032/156] feat: import-friendly csv configuration --- .vscode/launch.json | 35 +++++++ .vscode/tasks.json | 41 +++++++++ .../MappingProfileShould.cs | 91 +++++++++++++++++++ .../Controllers/BudgetController.cs | 5 +- .../MappingProfile.cs | 33 ++++++- .../Models/CsvFileReadingConfiguration.cs | 23 +++++ 6 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Models/CsvFileReadingConfiguration.cs diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..a2455a6e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md. + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/src/Hosts/NVs.Budget.Hosts.Web.Server/bin/Debug/net8.0/NVs.Budget.Hosts.Web.Server.dll", + "args": [], + "cwd": "${workspaceFolder}/src/Hosts/NVs.Budget.Hosts.Web.Server", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..327b8080 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/NVs.Budget.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/NVs.Budget.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/NVs.Budget.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web.Tests/MappingProfileShould.cs b/src/Controllers/NVs.Budget.Controllers.Web.Tests/MappingProfileShould.cs index 2911ddb3..857e87af 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web.Tests/MappingProfileShould.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web.Tests/MappingProfileShould.cs @@ -7,6 +7,7 @@ using NVs.Budget.Controllers.Web.Models; using NVs.Budget.Utilities.Expressions; using NVs.Budget.Utilities.Testing; +using NVs.Budget.Infrastructure.IO.Console.Options; namespace NVs.Budget.Controllers.Web.Tests; @@ -57,4 +58,94 @@ public void MapBudgetToBudgetConfiguration() configuration.Logbook.Should().NotBeNull(); configuration.Logbook.Should().HaveCount(expected.LogbookCriteria.Subcriteria?.Count ?? 0); } + + [Fact] + public void MapTaggingCriteriaToDictionary() + { + var source = _fixture.CreateMany().ToList(); + var result = _mapper.Map>>(source); + + result.Should().NotBeNull(); + result.Should().HaveCount(source.GroupBy(x => x.Tag.ToString()).Count()); + foreach (var group in source.GroupBy(x => x.Tag.ToString())) + { + result[group.Key].Should().BeEquivalentTo(group.Select(x => x.Condition.ToString())); + } + } + + [Fact] + public void MapTransferCriteriaToDictionary() + { + var source = _fixture.CreateMany().ToList(); + var result = _mapper.Map>>(source); + + result.Should().NotBeNull(); + result.Should().HaveCount(source.GroupBy(x => x.Comment).Count()); + foreach (var group in source.GroupBy(x => x.Comment)) + { + var expressions = result[group.Key].ToList(); + expressions.Should().HaveCount(group.GroupBy(x => x.Accuracy).Count()); + + foreach (var accuracyGroup in group.GroupBy(x => x.Accuracy)) + { + var expression = expressions.Single(e => e.Accuracy == accuracyGroup.Key); + expression.Criteria.Should().BeEquivalentTo(accuracyGroup.Select(x => x.Criterion.ToString())); + } + } + } + + [Fact] + public void MapLogbookCriteriaToDictionary() + { + var source = _fixture.Create(); + var result = _mapper.Map>(source); + + result.Should().NotBeNull(); + if (source.Subcriteria != null) + { + result.Should().HaveCount(source.Subcriteria.Count); + foreach (var subcriteria in source.Subcriteria) + { + var expression = result[subcriteria.Description]; + expression.Type.Should().Be(subcriteria.Type); + expression.Tags.Should().BeEquivalentTo(subcriteria.Tags?.Select(t => t.ToString())); + expression.Substitution.Should().Be(subcriteria.Substitution?.ToString()); + expression.Criteria.Should().Be(subcriteria.Criteria?.ToString()); + } + } + else + { + result.Should().BeEmpty(); + } + } + + [Fact] + public void MapFieldConfigurationToString() + { + var source = _fixture.Create(); + var result = _mapper.Map(source); + + result.Should().Be(source.Pattern); + } + + [Fact] + public void MapValidationRule() + { + var source = _fixture.Create(); + var result = _mapper.Map(source); + + result.Should().NotBeNull(); + result.Should().BeEquivalentTo(source); + } + + [Fact] + public void MapCsvFileReadingOptions() + { + var source = _fixture.Create(); + var result = _mapper.Map(source); + + result.Should().NotBeNull(); + result.CultureCode.Should().Be(source.CultureInfo.Name); + result.Should().BeEquivalentTo(source, opts => opts.Excluding(x => x.CultureInfo)); + } } diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index eb9e1fbf..48040839 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -178,7 +178,7 @@ public async Task UpdateCsvReadingOptions( [Produces("application/yaml")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetCsvReadingOptions(Guid id, CancellationToken ct) + public async Task>> GetCsvReadingOptions(Guid id, CancellationToken ct) { // Find existing budget var budget = (await manager.GetOwnedBudgets(ct)).FirstOrDefault(b => b.Id == id); @@ -188,6 +188,7 @@ public async Task> GetCsvReadingOptions(Guid id, } // Get and return options - will be automatically serialized to YAML - return await settingsRepository.GetReadingOptionsFor(budget, ct); + var options = await settingsRepository.GetReadingOptionsFor(budget, ct); + return Ok(mapper.Map>(options.Snapshot)); } } \ No newline at end of file diff --git a/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs b/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs index 2940f7ae..3ab53611 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/MappingProfile.cs @@ -17,12 +17,24 @@ public MappingProfile(ReadableExpressionsParser parser) .ForMember(dest => dest.Tags, opt => opt.MapFrom(src => src.TaggingCriteria)) .ForMember(dest => dest.Transfers, opt => opt.MapFrom(src => src.TransferCriteria)) .ForMember(dest => dest.Logbook, opt => opt.MapFrom(src => src.LogbookCriteria)); + + + CreateMap().ConvertUsing(src => src.Pattern); + + CreateMap() + .ForMember(dest => dest.FieldConfiguration, opt => opt.MapFrom(src => src.FieldConfiguration.Pattern)) + .ForMember(dest => dest.Condition, opt => opt.MapFrom(src => (CsvFileReadingConfiguration.ValidationCondition)src.Condition)) + .ForMember(dest => dest.Value, opt => opt.MapFrom(src => src.Value)); + + CreateMap() + .ConvertUsing(); } } internal class Converter : ITypeConverter, IDictionary>>, ITypeConverter, IDictionary>>, - ITypeConverter> + ITypeConverter>, + ITypeConverter { public IDictionary> Convert(IEnumerable source, IDictionary> destination, ResolutionContext context) { @@ -45,6 +57,25 @@ public IDictionary Convert(LogbookCriteria so return source.Subcriteria?.ToDictionary(s => s.Description, ConvertCriteria) ?? new(); } + public CsvFileReadingConfiguration Convert(Infrastructure.IO.Console.Options.CsvFileReadingOptions source, CsvFileReadingConfiguration _, ResolutionContext __) + { + var result = new CsvFileReadingConfiguration + { + CultureCode = source.CultureInfo.Name, + DateTimeKind = source.DateTimeKind, + Attributes = source.Attributes?.ToDictionary(x => x.Key, x => x.Value.Pattern).AsReadOnly(), + ValidationRules = source.ValidationRules?.ToDictionary(x => x.Key, x => x.Value.FieldConfiguration.Pattern).AsReadOnly() + }; + + // Add the field configurations to the dictionary + foreach (var kvp in source) + { + result[kvp.Key] = kvp.Value.Pattern; + } + + return result; + } + private LogbookCriteriaExpression ConvertCriteria(LogbookCriteria criteria) { var subcriteria = criteria.Subcriteria?.ToDictionary(s => s.Description, ConvertCriteria); diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/CsvFileReadingConfiguration.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/CsvFileReadingConfiguration.cs new file mode 100644 index 00000000..36b20cef --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/CsvFileReadingConfiguration.cs @@ -0,0 +1,23 @@ +namespace NVs.Budget.Controllers.Web.Models; + +public class CsvFileReadingConfiguration : Dictionary { + public string CultureCode { get; init; } + public DateTimeKind DateTimeKind { get; init; } + public IReadOnlyDictionary? Attributes {get; init;} + public IReadOnlyDictionary? ValidationRules {get; init;} + + public class ValidationRule { + public string FieldConfiguration {get; init;} + public string Value {get; init;} + public ValidationCondition Condition {get; init;} + } + + public enum ValidationCondition { + Equals, + NotEquals + } +} + + + + From fa628f70f581425284de233661215f010269f4ec Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 22 Jun 2025 16:39:02 +0300 Subject: [PATCH 033/156] chore: compose for debugging --- .gitignore | 3 +- .../NVs.Budget.Hosts.Web.Client/Dockerfile | 11 ++- .../budget-client/.dockerignore | 14 ++++ .../budget-client/angular.json | 4 +- .../budget-client/web-debug.Dockerfile | 23 +++++++ .../NVs.Budget.Hosts.Web.Server/Dockerfile | 18 ++--- .../Properties/launchSettings.json | 2 +- .../web-debug.Dockerfile | 5 ++ src/Hosts/docker-compose.yml | 15 ---- src/Hosts/web-debug/dev-certs.Dockerfile | 14 ++++ src/Hosts/web-debug/docker-compose.yml | 69 +++++++++++++++++++ src/Hosts/web-debug/server.env.example | 2 + 12 files changed, 152 insertions(+), 28 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.dockerignore create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/web-debug.Dockerfile create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Server/web-debug.Dockerfile delete mode 100644 src/Hosts/docker-compose.yml create mode 100644 src/Hosts/web-debug/dev-certs.Dockerfile create mode 100644 src/Hosts/web-debug/docker-compose.yml create mode 100644 src/Hosts/web-debug/server.env.example diff --git a/.gitignore b/.gitignore index 41a40683..d480b8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ bin obj *.user -/dist/ \ No newline at end of file +/dist/ +server.env \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/Dockerfile b/src/Hosts/NVs.Budget.Hosts.Web.Client/Dockerfile index e0886e36..f59d8e2c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/Dockerfile +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/Dockerfile @@ -4,10 +4,18 @@ WORKDIR /app EXPOSE 8080 EXPOSE 8081 +FROM node:20 AS node-builder +COPY ["NVs.Budget.Hosts.Web.Client/budget-client", "/client"] +RUN npm i -g @angular/cli +WORKDIR /client +RUN npm ci +RUN npm run build + + FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src -COPY ["NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj", "NVs.Budget.Hosts.Web.Client/"] +COPY --exclude="NVs.Budget.Hosts.Web.Client/budget-client" ["NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj", "NVs.Budget.Hosts.Web.Client/"] RUN dotnet restore "NVs.Budget.Hosts.Web.Client/NVs.Budget.Hosts.Web.Client.csproj" COPY . . WORKDIR "/src/NVs.Budget.Hosts.Web.Client" @@ -20,4 +28,5 @@ RUN dotnet publish "NVs.Budget.Hosts.Web.Client.csproj" -c $BUILD_CONFIGURATION FROM base AS final WORKDIR /app COPY --from=publish /app/publish . +COPY --from=node-builder /client/dist/budget-client /app/wwwroot ENTRYPOINT ["dotnet", "NVs.Budget.Hosts.Web.Client.dll"] diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.dockerignore b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.dockerignore new file mode 100644 index 00000000..93fc89da --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/.dockerignore @@ -0,0 +1,14 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.nyc_output +coverage +.nyc_output +.coverage +dist +.angular +.vscode +*.log \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json index e5617932..bcde9e2c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json @@ -83,8 +83,8 @@ "defaultConfiguration": "development", "options": { "ssl": true, - "sslCert": "./certificates/dev/nginx.crt", - "sslKey": "./certificates/dev/nginx.key" + "sslCert": "/https/aspnetapp.crt", + "sslKey": "/https/aspnetapp.key" } }, "extract-i18n": { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/web-debug.Dockerfile b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/web-debug.Dockerfile new file mode 100644 index 00000000..49b05888 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/web-debug.Dockerfile @@ -0,0 +1,23 @@ +# Use Node.js 20 as base image +FROM node:20-alpine + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Expose port 4200 (default Angular dev server port) +EXPOSE 4200 + +# Expose port 49153 (for hot reload) +EXPOSE 49153 + +# Start the development server +CMD ["npm", "start", "--", "--host", "0.0.0.0", "--port", "4200", "--poll", "2000"] \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/Dockerfile b/src/Hosts/NVs.Budget.Hosts.Web.Server/Dockerfile index 6052fd63..07a54d5d 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/Dockerfile +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/Dockerfile @@ -1,23 +1,25 @@ FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base USER $APP_UID WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 +EXPOSE 7237 +EXPOSE 5153 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src -COPY ["Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj", "Hosts/NVs.Budget.Hosts.Web/"] -RUN dotnet restore "Hosts/NVs.Budget.Hosts.Web/NVs.Budget.Hosts.Web.csproj" COPY . . -WORKDIR "/src/Hosts/NVs.Budget.Hosts.Web" -RUN dotnet build "NVs.Budget.Hosts.Web.csproj" -c $BUILD_CONFIGURATION -o /app/build +WORKDIR /src/src/Hosts/NVs.Budget.Hosts.Web.Server +RUN dotnet restore NVs.Budget.Hosts.Web.Server.csproj +RUN dotnet build NVs.Budget.Hosts.Web.Server.csproj -c $BUILD_CONFIGURATION -o /app/build FROM build AS publish ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "NVs.Budget.Hosts.Web.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +RUN dotnet publish NVs.Budget.Hosts.Web.Server.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS preps-development + FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "NVs.Budget.Hosts.Web.dll"] +ENTRYPOINT ["dotnet", "NVs.Budget.Hosts.Web.Server.dll", "--launch-profile", "https"] diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json b/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json index 964e3d00..81649f1d 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:7237;http://localhost:5153", + "applicationUrl": "https://+:7237;http://+:5153", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/web-debug.Dockerfile b/src/Hosts/NVs.Budget.Hosts.Web.Server/web-debug.Dockerfile new file mode 100644 index 00000000..749c44ed --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/web-debug.Dockerfile @@ -0,0 +1,5 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS debug +EXPOSE 7237 +EXPOSE 5153 +WORKDIR /budget/src/Hosts/NVs.Budget.Hosts.Web.Server +ENTRYPOINT ["dotnet", "watch", "run", "--project", "NVs.Budget.Hosts.Web.Server.csproj", "--", "--launch-profile", "https"] \ No newline at end of file diff --git a/src/Hosts/docker-compose.yml b/src/Hosts/docker-compose.yml deleted file mode 100644 index 170d3981..00000000 --- a/src/Hosts/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -services: - postgres: - image: postgres:17 - environment: - POSTGRES_DB: "budgetdb" - POSTGRES_USER: "postgres" - POSTGRES_PASSWORD: "postgres" - PGDATA: "/var/lib/postgresql/data/pgdata" - volumes: - - budgetdb-data:/var/lib/postgresql/data - ports: - - "20000:5432" - -volumes: - budgetdb-data: diff --git a/src/Hosts/web-debug/dev-certs.Dockerfile b/src/Hosts/web-debug/dev-certs.Dockerfile new file mode 100644 index 00000000..14561d36 --- /dev/null +++ b/src/Hosts/web-debug/dev-certs.Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG APP_UID=1000 +WORKDIR /https +RUN chmod 755 /https +RUN dotnet dev-certs https -ep /https/aspnetapp.pfx -p "dev-password-do-not-use-in-production" + +# Export certificate (CRT) from PFX file +RUN openssl pkcs12 -in /https/aspnetapp.pfx -out /https/aspnetapp.crt -nokeys -passin pass:"dev-password-do-not-use-in-production" + +# Export private key (KEY) from PFX file +RUN openssl pkcs12 -in /https/aspnetapp.pfx -out /https/aspnetapp.key -nocerts -nodes -passin pass:"dev-password-do-not-use-in-production" + +RUN chmod 644 /https/* +RUN chown $APP_UID:$APP_UID /https/* \ No newline at end of file diff --git a/src/Hosts/web-debug/docker-compose.yml b/src/Hosts/web-debug/docker-compose.yml new file mode 100644 index 00000000..40e5d5e7 --- /dev/null +++ b/src/Hosts/web-debug/docker-compose.yml @@ -0,0 +1,69 @@ +services: + postgres: + image: postgres:17 + environment: + POSTGRES_DB: "budgetdb" + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" + PGDATA: "/var/lib/postgresql/data/pgdata" + volumes: + - budgetdb-data:/var/lib/postgresql/data + ports: + - "20000:5432" + + dev-certs: + build: + context: . + dockerfile: ./dev-certs.Dockerfile + args: + - APP_UID=1000 + volumes: + - certs:/https + + budget-server: + build: + context: ../../ + dockerfile: ./Hosts/NVs.Budget.Hosts.Web.Server/web-debug.Dockerfile + args: + - BUILD_CONFIGURATION=Debug + - APP_UID=1000 + ports: + - "7237:7237" + - "5153:5153" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__BudgetContext=Host=postgres;Database=budgetdb;Username=postgres;Password=postgres + - ConnectionStrings__IdentityContext=Host=postgres;Database=budgetdb;Username=postgres;Password=postgres + - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx + - ASPNETCORE_Kestrel__Certificates__Default__Password=dev-password-do-not-use-in-production + env_file: + - server.env + volumes: + - ../../../.:/budget + - certs:/https + depends_on: + - postgres + - dev-certs + + budget-client: + build: + context: ../NVs.Budget.Hosts.Web.Client/budget-client + dockerfile: web-debug.Dockerfile + ports: + - "4200:4200" + - "49153:49153" + volumes: + # Mount source code for hot reload + - ../NVs.Budget.Hosts.Web.Client/budget-client:/app + - /app/node_modules + - certs:/https + environment: + - NODE_ENV=development + depends_on: + - postgres + - budget-server + - dev-certs + +volumes: + budgetdb-data: + certs: diff --git a/src/Hosts/web-debug/server.env.example b/src/Hosts/web-debug/server.env.example new file mode 100644 index 00000000..bda5674f --- /dev/null +++ b/src/Hosts/web-debug/server.env.example @@ -0,0 +1,2 @@ +Auth__Yandex__ClientSecret = your_client_secret +Auth__Yandex__ClientId = your_client_id From a577a81cace385253151ba5759c21f8613e82621 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 22 Jun 2025 16:48:10 +0300 Subject: [PATCH 034/156] chore: renamed budget-detail to budget-settings --- .../budget-client/src/app/app.routes.ts | 4 ++-- .../budget-detail/budget-detail.component.less | 0 .../budget-selector/budget-selector.component.html | 2 +- .../budget-selector/budget-selector.component.ts | 2 +- .../budget-settings.component.html} | 2 +- .../budget-settings/budget-settings.component.less | 1 + .../budget-settings.component.spec.ts} | 14 +++++++------- .../budget-settings.component.ts} | 10 +++++----- 8 files changed, 18 insertions(+), 17 deletions(-) delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less rename src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/{budget-detail/budget-detail.component.html => budget-settings/budget-settings.component.html} (95%) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less rename src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/{budget-detail/budget-detail.component.spec.ts => budget-settings/budget-settings.component.spec.ts} (50%) rename src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/{budget-detail/budget-detail.component.ts => budget-settings/budget-settings.component.ts} (75%) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts index 02036a5f..8f49644e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts @@ -1,10 +1,10 @@ import { Routes } from '@angular/router'; import { NewBudgetComponent } from './budget/new-budget/new-budget.component'; -import { BudgetDetailComponent } from './budget/budget-detail/budget-detail.component'; +import { BudgetSettingsComponent } from './budget/budget-settings/budget-settings.component'; import { IndexComponent } from './index/index.component'; export const routes: Routes = [ { path: 'budget/new', component: NewBudgetComponent }, - { path: 'budget/:budgetId', component: BudgetDetailComponent }, + { path: 'budget/:budgetId/settings', component: BudgetSettingsComponent }, { path: '', component: IndexComponent } ]; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html index 7137e922..f55b3ec6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html @@ -53,7 +53,7 @@ - + {{budget.name}} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index c6a5d0de..3cca6d5e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -14,7 +14,7 @@ import { AsyncPipe, CommonModule } from '@angular/common'; imports: [CommonModule, TuiButton, TuiChevron, TuiDataList, TuiDropdown, RouterLink, AsyncPipe] }) export class BudgetSelectorComponent implements OnInit, OnDestroy { - private budgetIdPattern = new RegExp("^/budget/([^/]*)$"); + private budgetIdPattern = new RegExp("^/budget/([^/]*)/settings$"); private budgetSub: Subscription | undefined; private routerSub: Subscription | undefined; private selectedBudgetId: string | null = null; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html similarity index 95% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html index e9432985..b77f9d75 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html @@ -5,4 +5,4 @@ download > Download as YAML - + \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less new file mode 100644 index 00000000..b1e0f723 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less @@ -0,0 +1 @@ +// Budget settings component styles \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.spec.ts similarity index 50% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.spec.ts index e3a7bcb3..14bc4d57 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.spec.ts @@ -1,18 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BudgetDetailComponent } from './budget-detail.component'; +import { BudgetSettingsComponent } from './budget-settings.component'; -describe('BudgetDetailComponent', () => { - let component: BudgetDetailComponent; - let fixture: ComponentFixture; +describe('BudgetSettingsComponent', () => { + let component: BudgetSettingsComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [BudgetDetailComponent] + imports: [BudgetSettingsComponent] }) .compileComponents(); - fixture = TestBed.createComponent(BudgetDetailComponent); + fixture = TestBed.createComponent(BudgetSettingsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); @@ -20,4 +20,4 @@ describe('BudgetDetailComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); -}); +}); \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts similarity index 75% rename from src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts rename to src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts index 49c64653..39276352 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts @@ -5,13 +5,13 @@ import { Observable, map } from 'rxjs'; import { BudgetApiService } from '../budget-api.service'; @Component({ - selector: 'app-budget-detail', + selector: 'app-budget-settings', imports: [AsyncPipe], - templateUrl: './budget-detail.component.html', - styleUrl: './budget-detail.component.less' + templateUrl: './budget-settings.component.html', + styleUrl: './budget-settings.component.less' }) -export class BudgetDetailComponent implements OnInit { +export class BudgetSettingsComponent implements OnInit { budgetId$?: Observable; constructor(private route: ActivatedRoute, apiService: BudgetApiService) { @@ -23,4 +23,4 @@ export class BudgetDetailComponent implements OnInit { ngOnInit(): void { this.budgetId$ = this.route.params.pipe(map(params => params['budgetId'])); } -} +} \ No newline at end of file From a0105181417f4b044d0cf88336e7177511287e5b Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 22 Jun 2025 17:12:59 +0300 Subject: [PATCH 035/156] wip: styling issues --- .../budget-selector.component.html | 44 ------- .../budget-selector.component.ts | 90 +-------------- .../budget-settings.component.html | 68 +++++++++-- .../budget-settings.component.less | 39 ++++++- .../budget-settings.component.ts | 108 +++++++++++++++++- 5 files changed, 206 insertions(+), 143 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html index f55b3ec6..642971e8 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html @@ -6,50 +6,6 @@ {{(selectedBudget$ | async)?.name ?? "Выбери бюджет из списка..."}} - - - - - - - - diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index 3cca6d5e..a1dea882 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { BudgetApiService as BudgetApiService } from '../budget-api.service'; import { BudgetResponse } from '../models'; import { NavigationEnd, Router, RouterLink } from '@angular/router'; -import { TuiButton, TuiDataList, TuiDropdown, TuiLink, TuiDialogService } from '@taiga-ui/core'; +import { TuiButton, TuiDataList, TuiDropdown } from '@taiga-ui/core'; import { TuiChevron } from '@taiga-ui/kit' import { BehaviorSubject, filter, Observable, Subscription } from 'rxjs'; import { AsyncPipe, CommonModule } from '@angular/common'; @@ -25,9 +25,9 @@ export class BudgetSelectorComponent implements OnInit, OnDestroy { constructor( private budgetApiService: BudgetApiService, - private router: Router, - private dialogService: TuiDialogService + private router: Router ) {} + ngOnDestroy(): void { console.log('destroying budget selector component'); this.budgetSub?.unsubscribe(); @@ -63,88 +63,4 @@ export class BudgetSelectorComponent implements OnInit, OnDestroy { this.selectedBudget$.next(undefined); } } - - downloadBudget() { - if (this.selectedBudgetId) { - this.budgetApiService.downloadBudgetYaml(this.selectedBudgetId).subscribe(blob => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `budget-${this.selectedBudgetId}.yaml`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - }); - } - } - - onFileSelected(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - const file = input.files[0]; - this.budgetApiService.uploadBudgetYaml(file).subscribe({ - next: () => { - // Refresh the budget list after successful upload - this.budgets$ = this.budgetApiService.getAllBudgets(); - }, - error: (error) => { - let errorMessage = 'Произошла ошибка при загрузке файла.'; - if (error.status === 400 && error.error instanceof Array) { - errorMessage = error.error.map((err: any) => err.message).join(', '); - } - this.dialogService.open(errorMessage, { - label: 'Ошибка загрузки', - size: 'm', - closeable: true, - dismissible: true, - }).subscribe(); - } - }); - } - } - - downloadCsvOptions() { - if (this.selectedBudgetId) { - this.budgetApiService.downloadCsvOptionsYaml(this.selectedBudgetId).subscribe(blob => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `csv-options-${this.selectedBudgetId}.yaml`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - }); - } - } - - onCsvOptionsFileSelected(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0 && this.selectedBudgetId) { - const file = input.files[0]; - this.budgetApiService.uploadCsvOptionsYaml(this.selectedBudgetId, file).subscribe({ - next: () => { - this.dialogService.open('CSV options updated successfully', { - label: 'Success', - size: 'm', - closeable: true, - dismissible: true, - }).subscribe(); - }, - error: (error) => { - let errorMessage = 'Error uploading CSV options.'; - if (error.status === 400 && error.error instanceof Array) { - errorMessage = error.error.map((err: any) => err.message).join(', '); - } - this.dialogService.open(errorMessage, { - label: 'Upload Error', - size: 'm', - closeable: true, - dismissible: true, - }).subscribe(); - } - }); - } - } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html index b77f9d75..27c20dbd 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html @@ -1,8 +1,60 @@ -

BudgetId: {{budgetId$ | async}}

- - Download as YAML - \ No newline at end of file +
+

Budget Settings

+

Budget ID: {{budgetId$ | async}}

+ +
+

Budget Configuration

+
+ + + +
+
+ +
+

CSV Import Options

+
+ + + +
+
+
\ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less index b1e0f723..9d893f79 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less @@ -1 +1,38 @@ -// Budget settings component styles \ No newline at end of file +.budget-settings-container { + height: 100%; + padding: 2rem; + + .budget-id { + margin-bottom: 2rem; + color: var(--tui-text-03); + font-size: 1rem; + } +} + +.settings-section { + margin-bottom: 2.5rem; + padding: 1.5rem; + border: 1px solid var(--tui-base-03); + border-radius: var(--tui-radius-m); + background: var(--tui-base-01); + + &:last-child { + margin-bottom: 0; + } +} + +.button-group { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: center; + + button { + min-width: 200px; + } +} + +.download-link { + text-decoration: none; + display: inline-block; +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts index 39276352..38ad1e36 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts @@ -3,18 +3,24 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Observable, map } from 'rxjs'; import { BudgetApiService } from '../budget-api.service'; +import { TuiButton, TuiDialogService } from '@taiga-ui/core'; @Component({ selector: 'app-budget-settings', - imports: [AsyncPipe], + imports: [AsyncPipe, TuiButton], templateUrl: './budget-settings.component.html', - styleUrl: './budget-settings.component.less' + styleUrls: ['./budget-settings.component.less'] }) export class BudgetSettingsComponent implements OnInit { budgetId$?: Observable; + budgetId: string | null = null; - constructor(private route: ActivatedRoute, apiService: BudgetApiService) { + constructor( + private route: ActivatedRoute, + private apiService: BudgetApiService, + private dialogService: TuiDialogService + ) { this.baseUrl = apiService.baseUrl; } @@ -22,5 +28,101 @@ export class BudgetSettingsComponent implements OnInit { ngOnInit(): void { this.budgetId$ = this.route.params.pipe(map(params => params['budgetId'])); + this.budgetId$.subscribe(id => { + this.budgetId = id; + }); + } + + downloadBudget() { + if (this.budgetId) { + this.apiService.downloadBudgetYaml(this.budgetId).subscribe(blob => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `budget-${this.budgetId}.yaml`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }); + } + } + + onFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + this.apiService.uploadBudgetYaml(file).subscribe({ + next: () => { + this.dialogService.open('Budget uploaded successfully', { + label: 'Success', + size: 'm', + closeable: true, + dismissible: true, + }).subscribe(() => { + window.location.reload(); + }); + }, + error: (error) => { + let errorMessage = 'Произошла ошибка при загрузке файла.'; + if (error.status === 400 && error.error instanceof Array) { + errorMessage = error.error.map((err: any) => err.message).join(', '); + } + this.dialogService.open(errorMessage, { + label: 'Ошибка загрузки', + size: 'm', + closeable: true, + dismissible: true, + }).subscribe(); + } + }); + } + } + + downloadCsvOptions() { + if (this.budgetId) { + this.apiService.downloadCsvOptionsYaml(this.budgetId).subscribe(blob => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `csv-options-${this.budgetId}.yaml`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }); + } + } + + onCsvOptionsFileSelected(event: Event) { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0 && this.budgetId) { + const file = input.files[0]; + this.apiService.uploadCsvOptionsYaml(this.budgetId, file).subscribe({ + next: () => { + this.dialogService.open('CSV options updated successfully', { + label: 'Success', + size: 'm', + closeable: true, + dismissible: true, + }).subscribe(() => { + // Refresh the page when user clicks "Ok" + window.location.reload(); + }); + }, + error: (error) => { + let errorMessage = 'Error uploading CSV options.'; + if (error.status === 400 && error.error instanceof Array) { + errorMessage = error.error.map((err: any) => err.message).join(', '); + } + this.dialogService.open(errorMessage, { + label: 'Upload Error', + size: 'm', + closeable: true, + dismissible: true, + }).subscribe(); + } + }); + } } } \ No newline at end of file From 52efcd376418cc20f0023d2f000c2e54ca01e498 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 22 Jun 2025 17:41:27 +0300 Subject: [PATCH 036/156] fix: fixed less compilation --- .../budget-selector.component.less | 11 +----- .../budget-settings.component.html | 10 ++--- .../budget-settings.component.less | 38 ------------------- 3 files changed, 4 insertions(+), 55 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less index f122f69d..d9ae495f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.less @@ -2,13 +2,4 @@ display: flex; gap: 8px; align-items: center; -} - -.download-button, -.upload-button { - padding: 0 8px; - min-width: 40px; - height: 40px; - font-size: 20px; - line-height: 1; -} +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html index 27c20dbd..18181082 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html @@ -1,4 +1,4 @@ -
+

Budget Settings

Budget ID: {{budgetId$ | async}}

@@ -8,15 +8,13 @@

Budget Configuration

+ + + } +
+ + +
+
+
+ + {{ budget.id }} +
+
+ + {{ budget.version }} +
+
+ + +
+

Owners

+
+ @for (owner of budget.owners; track owner.id) { + + {{ owner.name }} + + } +
+
+
+ + + @if (isEditMode && budgetForm) { +
+ +
+

Basic Information

+ + + + +
+ + +
+
+

Tagging Criteria

+ +
+ + +
+ @for (criterion of taggingCriteria.controls; track $index; let i = $index) { + +
+ Tagging Criterion {{ i + 1 }} + +
+ +
+ + + + + + + + + +
+
+ } +
+
+
+ + +
+
+

Transfer Criteria

+ +
+ + +
+ @for (criterion of transferCriteria.controls; track $index; let i = $index) { + +
+ Transfer Criterion {{ i + 1 }} + +
+ +
+ + + + + + + + + + + + + + +
+
+ } +
+
+
+ + +
+ + +
+
+ } @else { + +
+ + @if (budget.taggingCriteria.length > 0) { +
+

Tagging Criteria

+ @for (criterion of budget.taggingCriteria; track $index) { +
+
+ Tag: + {{ criterion.tag }} +
+
+ Condition: + {{ criterion.condition }} +
+
+ } +
+ } + + + @if (budget.transferCriteria.length > 0) { +
+

Transfer Criteria

+ @for (criterion of budget.transferCriteria; track $index) { +
+
+ Accuracy: + {{ criterion.accuracy }} +
+
+ Comment: + {{ criterion.comment }} +
+
+ Criterion: + {{ criterion.criterion }} +
+
+ } +
+ } + + +
+

Logbook Criteria

+
+
+ Description: + {{ budget.logbookCriteria.description || 'N/A' }} +
+ @if (budget.logbookCriteria.isUniversal) { + Universal + } + @if (budget.logbookCriteria.tags) { +
+ Tags: +
+ @for (tag of budget.logbookCriteria.tags; track tag) { + {{ tag }} + } +
+
+ } + @if (budget.logbookCriteria.criteria) { +
+ Criteria: + {{ budget.logbookCriteria.criteria }} +
+ } +
+
+
+ } +
+ + } @else { + +
Loading budget...
+
+ } +
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less new file mode 100644 index 00000000..9ae67da3 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less @@ -0,0 +1,159 @@ +.budget-detail-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.budget-card { + margin-bottom: 2rem; +} + +.header-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.budget-info-section { + padding: 1.5rem 0; + border-bottom: 1px solid var(--tui-border-normal); + margin-bottom: 1.5rem; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.info-label { + font-size: 0.875rem; + color: var(--tui-text-secondary); + font-weight: 500; +} + +.info-value { + font-size: 0.875rem; + padding: 0.25rem 0.5rem; + background: var(--tui-background-neutral-1); + border-radius: 4px; + font-family: 'Courier New', monospace; +} + +.owners-section { + margin-top: 1.5rem; +} + +.owners-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.budget-form { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.form-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.criterion-fields { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; +} + +.form-actions { + display: flex; + gap: 1rem; + padding-top: 1.5rem; + border-top: 1px solid var(--tui-border-normal); +} + +.read-only-view { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.criteria-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.criterion-card { + background: var(--tui-background-neutral-1); + border: 1px solid var(--tui-border-normal); + border-radius: 8px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.criterion-row { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.criterion-label { + font-size: 0.875rem; + color: var(--tui-text-secondary); + font-weight: 500; +} + +.criterion-value { + font-size: 0.875rem; + padding: 0.5rem; + background: var(--tui-background-base); + border-radius: 4px; + word-break: break-all; + font-family: 'Courier New', monospace; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.loading-placeholder { + padding: 3rem; + text-align: center; + color: var(--tui-text-secondary); +} + +tui-accordion-item { + margin-bottom: 0.5rem; + + header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts new file mode 100644 index 00000000..2c20ed5e --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BudgetDetailComponent } from './budget-detail.component'; + +describe('BudgetDetailComponent', () => { + let component: BudgetDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BudgetDetailComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(BudgetDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts new file mode 100644 index 00000000..b2ebb0f8 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts @@ -0,0 +1,235 @@ +import { AsyncPipe, CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, FormArray } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable, map, switchMap, catchError, of, tap } from 'rxjs'; +import { BudgetApiService } from '../budget-api.service'; +import { BudgetResponse, UpdateBudgetRequest, Owner } from '../models'; +import { + TuiButton, + TuiDialogService, + TuiLoader, + TuiTitle, + TuiTextfield, + TuiLabel +} from '@taiga-ui/core'; +import { + TuiAccordion, + TuiChip, + TuiTextarea +} from '@taiga-ui/kit'; +import { TuiCardLarge } from '@taiga-ui/layout'; + +@Component({ + selector: 'app-budget-detail', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TuiButton, + TuiLoader, + TuiTextfield, + TuiLabel, + TuiCardLarge, + TuiAccordion, + TuiChip, + TuiTextarea, + TuiTitle + ], + templateUrl: './budget-detail.component.html', + styleUrls: ['./budget-detail.component.less'] +}) +export class BudgetDetailComponent implements OnInit { + budgetId$!: Observable; + budget$!: Observable; + budget: BudgetResponse | null = null; + + budgetForm!: FormGroup; + isEditMode = false; + isLoading = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private apiService: BudgetApiService, + private fb: FormBuilder, + private dialogService: TuiDialogService + ) {} + + ngOnInit(): void { + this.budgetId$ = this.route.params.pipe(map(params => params['budgetId'])); + + this.budget$ = this.budgetId$.pipe( + switchMap(id => this.apiService.getBudgetById(id).pipe( + tap(budget => { + this.budget = budget || null; + this.initForm(); + }), + catchError(error => { + console.error('Error fetching budget:', error); + this.showError('Failed to load budget details'); + return of(undefined); + }) + )) + ); + } + + initForm(): void { + if (!this.budget) return; + + this.budgetForm = this.fb.group({ + name: [this.budget.name, Validators.required], + version: [this.budget.version], + taggingCriteria: this.fb.array( + this.budget.taggingCriteria.map(tc => this.fb.group({ + tag: [tc.tag, Validators.required], + condition: [tc.condition, Validators.required] + })) + ), + transferCriteria: this.fb.array( + this.budget.transferCriteria.map(tc => this.fb.group({ + accuracy: [tc.accuracy, Validators.required], + comment: [tc.comment, Validators.required], + criterion: [tc.criterion, Validators.required] + })) + ) + }); + } + + get taggingCriteria(): FormArray { + return this.budgetForm?.get('taggingCriteria') as FormArray; + } + + get transferCriteria(): FormArray { + return this.budgetForm?.get('transferCriteria') as FormArray; + } + + toggleEditMode(): void { + this.isEditMode = !this.isEditMode; + if (!this.isEditMode) { + this.initForm(); + } + } + + addTaggingCriterion(): void { + this.taggingCriteria.push(this.fb.group({ + tag: ['', Validators.required], + condition: ['', Validators.required] + })); + } + + removeTaggingCriterion(index: number): void { + this.taggingCriteria.removeAt(index); + } + + addTransferCriterion(): void { + this.transferCriteria.push(this.fb.group({ + accuracy: ['Exact', Validators.required], + comment: ['', Validators.required], + criterion: ['', Validators.required] + })); + } + + removeTransferCriterion(index: number): void { + this.transferCriteria.removeAt(index); + } + + saveBudget(): void { + if (!this.budgetForm.valid || !this.budget) return; + + this.isLoading = true; + const formValue = this.budgetForm.value; + + const request: UpdateBudgetRequest = { + name: formValue.name, + version: this.budget.version, + taggingCriteria: formValue.taggingCriteria, + transferCriteria: formValue.transferCriteria + }; + + this.apiService.updateBudget(this.budget.id, request).subscribe({ + next: () => { + this.isLoading = false; + this.isEditMode = false; + this.showSuccess('Budget updated successfully'); + window.location.reload(); + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to update budget'); + } + }); + } + + deleteBudget(): void { + if (!this.budget) return; + + const confirmed = confirm('Are you sure you want to delete this budget? This action cannot be undone.'); + if (confirmed && this.budget) { + this.isLoading = true; + this.apiService.removeBudget(this.budget.id, this.budget.version).subscribe({ + next: () => { + this.isLoading = false; + this.showSuccess('Budget deleted successfully'); + this.router.navigate(['/']); + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to delete budget'); + } + }); + } + } + + downloadYaml(): void { + if (!this.budget) return; + + this.apiService.downloadBudgetYaml(this.budget.id).subscribe({ + next: (blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `budget-${this.budget!.name}.yaml`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }, + error: (error) => { + this.handleError(error, 'Failed to download YAML'); + } + }); + } + + private handleError(error: any, defaultMessage: string): void { + let errorMessage = defaultMessage; + + if (error.status === 400 && Array.isArray(error.error)) { + const errors = error.error as any[]; + errorMessage = errors.map(e => e.message || e).join('; '); + } else if (error.error?.message) { + errorMessage = error.error.message; + } + + this.showError(errorMessage); + } + + private showError(message: string): void { + this.dialogService.open(message, { + label: 'Error', + size: 'm', + closeable: true, + dismissible: true + }).subscribe(); + } + + private showSuccess(message: string): void { + this.dialogService.open(message, { + label: 'Success', + size: 's', + closeable: true, + dismissible: true + }).subscribe(); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index a1dea882..551656c6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -14,7 +14,7 @@ import { AsyncPipe, CommonModule } from '@angular/common'; imports: [CommonModule, TuiButton, TuiChevron, TuiDataList, TuiDropdown, RouterLink, AsyncPipe] }) export class BudgetSelectorComponent implements OnInit, OnDestroy { - private budgetIdPattern = new RegExp("^/budget/([^/]*)/settings$"); + private budgetIdPattern = new RegExp("^/budget/([^/]*)(/.*)?$"); private budgetSub: Subscription | undefined; private routerSub: Subscription | undefined; private selectedBudgetId: string | null = null; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts index af34b312..1d5b36e6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts @@ -15,7 +15,7 @@ import { TuiButton, TuiDialogService } from '@taiga-ui/core'; export class BudgetSettingsComponent implements OnInit { budgetId$?: Observable; - budget$?: Observable; + budget$?: Observable; budgetId: string | null = null; budget: BudgetResponse | null = null; @@ -52,8 +52,8 @@ export class BudgetSettingsComponent implements OnInit { ); // Subscribe to budget$ to store the budget data - this.budget$.subscribe(budget => { - this.budget = budget; + this.budget$?.subscribe(budget => { + this.budget = budget || null; }); } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts index be23ec37..6b6f507e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts @@ -1,23 +1,69 @@ -// models.ts +// Budget models matching C# API +export interface Owner { + id: string; + name: string; +} + +export interface TaggingCriterionResponse { + tag: string; + condition: string; +} + +export interface TransferCriterionResponse { + accuracy: string; + comment: string; + criterion: string; +} + +export interface LogbookCriteriaResponse { + description: string; + subcriteria?: LogbookCriteriaResponse[]; + type?: string; + tags?: string[]; + substitution?: string; + criteria?: string; + isUniversal?: boolean; +} + export interface BudgetResponse { - id: string; - name?: string; - version?: string; - taggingCriteria?: string; - transferCriteria?: string; - logbookCriteria?: string; - } - - export interface CreateBudgetRequest { - name?: string; - } - - export interface IError { - message?: string; - metadata?: any; - reasons?: IError[]; - } - - export interface UpdateBudgetRequest extends BudgetResponse { - id: string; - } \ No newline at end of file + id: string; + name: string; + version: string; + owners: Owner[]; + taggingCriteria: TaggingCriterionResponse[]; + transferCriteria: TransferCriterionResponse[]; + logbookCriteria: LogbookCriteriaResponse; +} + +export interface BudgetIdentifier { + id: string; + version: string; +} + +export interface RegisterBudgetRequest { + name: string; +} + +export interface ChangeBudgetOwnersRequest { + budget: BudgetIdentifier; + ownerIds: string[]; +} + +export interface UpdateBudgetRequest { + name: string; + version: string; + taggingCriteria?: TaggingCriterionResponse[]; + transferCriteria?: TransferCriterionResponse[]; + logbookCriteria?: LogbookCriteriaResponse; +} + +export interface MergeBudgetsRequest { + budgetIds: string[]; + purgeEmptyBudgets: boolean; +} + +export interface IError { + message?: string; + metadata?: any; + reasons?: IError[]; +} \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts index 64048bb8..3c0dea45 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { BudgetApiService } from '../budget-api.service'; -import { CreateBudgetRequest } from '../models'; +import { RegisterBudgetRequest } from '../models'; import { CommonModule } from '@angular/common'; import { TuiButton, TuiError, TuiNotification, TuiTextfield } from '@taiga-ui/core'; import { TuiFieldErrorPipe, tuiValidationErrorsProvider } from '@taiga-ui/kit'; @@ -13,7 +13,7 @@ import { Router } from '@angular/router'; templateUrl: './new-budget.component.html', styleUrls: ['./new-budget.component.less'], imports: [FormsModule, ReactiveFormsModule, CommonModule, TuiNotification, TuiTextfield, TuiButton, TuiError, TuiFieldErrorPipe, TuiForm], - providers: [tuiValidationErrorsProvider({required: 'Пожалуйста, введите название бюджета'})] + providers: [tuiValidationErrorsProvider({required: 'Please enter budget name'})] }) export class NewBudgetComponent { nameGroup = new FormGroup({ @@ -26,11 +26,11 @@ export class NewBudgetComponent { onSubmit() { if (!this.nameGroup.controls.name.valid) { - this.errorMessage = 'Пожалуйста, введите название бюджета.'; + this.errorMessage = 'Please enter budget name.'; return; } - const request: CreateBudgetRequest = { + const request: RegisterBudgetRequest = { name: this.nameGroup.controls.name.value ?? '', }; @@ -51,10 +51,10 @@ export class NewBudgetComponent { } handleError(error: any) { - if (error.status === 400 && error.error instanceof Array) { + if (error.status === 400 && Array.isArray(error.error)) { this.errorMessage = error.error.map((err: any) => err.message).join(', '); } else { - this.errorMessage = 'Произошла ошибка при создании бюджета. Попробуйте снова позже.'; + this.errorMessage = 'Error creating budget. Please try again.'; } } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.html index 4489d964..66900150 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.html @@ -1,6 +1,93 @@ -
-
    -
  1. Нажми "Войти", чтобы начать
  2. -
  3. Выбери бюджет
  4. -
+
+ @if (isAuthenticated$ | async) { +
+ + + @if (budgets$ | async; as budgets) { + @if (budgets.length > 0) { +
+ @for (budget of budgets; track budget.id) { +
+

{{ budget.name }}

+ +
+
+ Owners: +
+ @for (owner of budget.owners; track owner.id) { + + {{ owner.name }} + + } +
+
+ +
+ Tagging Rules: + + {{ budget.taggingCriteria.length }} + +
+ +
+ Transfer Rules: + + {{ budget.transferCriteria.length }} + +
+
+ +
+ +
+
+ } +
+ } @else { +
+

No budgets yet

+

Create your first budget to get started!

+ +
+ } + } @else { + +
Loading budgets...
+
+ } +
+ } @else { +
+

Welcome to Budget Manager

+

Please log in to manage your budgets.

+
+ }
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less index f99f6189..19ac68d1 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less @@ -1,3 +1,101 @@ -.strikethrough { - text-decoration: line-through; +.index-container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.budgets-page { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.budgets-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; +} + +.budget-item { + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + } +} + +.budget-summary { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin: 1rem 0; +} + +.summary-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.summary-label { + font-size: 0.875rem; + color: var(--tui-text-secondary); + min-width: 100px; +} + +.owners-chips { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.budget-actions { + display: flex; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--tui-border-normal); +} + +.empty-state { + text-align: center; + padding: 3rem; + + h3 { + margin-bottom: 0.5rem; + } + + p { + color: var(--tui-text-secondary); + margin-bottom: 1.5rem; + } +} + +.welcome-card { + text-align: center; + padding: 3rem; + max-width: 600px; + margin: 2rem auto; + + h2 { + margin-bottom: 1rem; + } + + p { + color: var(--tui-text-secondary); + } +} + +.loading-placeholder { + padding: 3rem; + text-align: center; + color: var(--tui-text-secondary); } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.ts index e55767ce..c5842e22 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.ts @@ -1,20 +1,79 @@ import { Component } from '@angular/core'; -import { TuiGroup } from '@taiga-ui/core'; +import { Router } from '@angular/router'; +import { TuiButton, TuiDialogService, TuiLoader, TuiTitle } from '@taiga-ui/core'; import { UserService } from '../auth/user.service'; -import { Observable, map } from 'rxjs'; -import { AsyncPipe, NgIf } from '@angular/common'; +import { BudgetApiService } from '../budget/budget-api.service'; +import { BudgetResponse } from '../budget/models'; +import { Observable, map, catchError, of } from 'rxjs'; +import { AsyncPipe, CommonModule } from '@angular/common'; +import { TuiCardLarge } from '@taiga-ui/layout'; +import { TuiChip } from '@taiga-ui/kit'; @Component({ selector: 'app-index', - imports: [TuiGroup, AsyncPipe, NgIf], + standalone: true, + imports: [ + CommonModule, + AsyncPipe, + TuiButton, + TuiCardLarge, + TuiChip, + TuiLoader, + TuiTitle + ], templateUrl: './index.component.html', styleUrl: './index.component.less' }) export class IndexComponent { - isAuthenticated$: Observable; + budgets$: Observable; + + constructor( + private user: UserService, + private budgetService: BudgetApiService, + private router: Router, + private dialogService: TuiDialogService + ) { + this.isAuthenticated$ = user.current$.pipe(map(u => u.isAuthenticated)); + this.budgets$ = this.budgetService.getAllBudgets().pipe( + catchError(error => { + console.error('Error loading budgets:', error); + return of([]); + }) + ); + } + + createNewBudget(): void { + this.router.navigate(['/budget/new']); + } + + viewBudget(budgetId: string): void { + this.router.navigate(['/budget', budgetId]); + } - constructor(user: UserService) { - this.isAuthenticated$ = user.current$.pipe(map(user => user.isAuthenticated)); + deleteBudget(budget: BudgetResponse, event: Event): void { + event.stopPropagation(); + + const confirmed = confirm(`Are you sure you want to delete budget "${budget.name}"?`); + if (confirmed) { + this.budgetService.removeBudget(budget.id, budget.version).subscribe({ + next: () => { + this.dialogService.open('Budget deleted successfully', { + label: 'Success', + size: 's' + }).subscribe(); + }, + error: (error) => { + let errorMessage = 'Failed to delete budget'; + if (error.status === 400 && Array.isArray(error.error)) { + errorMessage = error.error.map((err: any) => err.message).join('; '); + } + this.dialogService.open(errorMessage, { + label: 'Error', + size: 'm' + }).subscribe(); + } + }); + } } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj b/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj index 2e29fbbc..a06be1d2 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/NVs.Budget.Hosts.Web.Server.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs index e0a125c6..626e4204 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs +++ b/src/Hosts/NVs.Budget.Hosts.Web.Server/Program.cs @@ -49,6 +49,7 @@ opts.AddDefaultPolicy(b => b.WithOrigins(allowedOrigins.Split(';')).AllowCredentials().AllowAnyHeader().AllowAnyMethod()); }) .AddCsvFiles(contentConnectionString) + .AddSingleton(ReadableExpressionsParser.Default) .AddWebControllers(); var app = builder.Build(); From ea9c16578bebf093ca5a4c6cb846778a1b4d2e5f Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Mon, 6 Oct 2025 21:33:23 +0300 Subject: [PATCH 060/156] fix: bumped TaigaUI to fix textarea --- .../budget-client/package-lock.json | 292 ++++++++++-------- .../budget-client/package.json | 18 +- 2 files changed, 168 insertions(+), 142 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json index 9b87005e..fa6f1578 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package-lock.json @@ -16,15 +16,15 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", - "@taiga-ui/addon-charts": "^4.26.1", - "@taiga-ui/addon-commerce": "^4.26.1", - "@taiga-ui/addon-table": "^4.26.1", - "@taiga-ui/cdk": "^4.26.1", - "@taiga-ui/core": "^4.26.1", - "@taiga-ui/event-plugins": "^4.0.2", - "@taiga-ui/icons": "^4.26.1", - "@taiga-ui/kit": "^4.26.1", - "@taiga-ui/layout": "^4.26.1", + "@taiga-ui/addon-charts": "^4.57.0", + "@taiga-ui/addon-commerce": "^4.57.0", + "@taiga-ui/addon-table": "^4.57.0", + "@taiga-ui/cdk": "^4.57.0", + "@taiga-ui/core": "^4.57.0", + "@taiga-ui/event-plugins": "^4.7.0", + "@taiga-ui/icons": "^4.57.0", + "@taiga-ui/kit": "^4.57.0", + "@taiga-ui/layout": "^4.57.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -3888,9 +3888,9 @@ ] }, "node_modules/@maskito/angular": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.3.0.tgz", - "integrity": "sha512-gClGPssn5S9eYaA/U1DlybNo5rGOCbdCXmrPrT1JuZ24yECsmAMabFaAbgDNFe0q+vy9PoujMxpw13lB2y85oA==", + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.11.1.tgz", + "integrity": "sha512-+OZzbRJj/9fOGhgPr0xYctSHe/Ngahip3VdNWBslRTpt7g+UTBYcB8vU9J4cHfpdXYeLM3tM0tnKksc3Eis0+Q==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -3899,35 +3899,35 @@ "peerDependencies": { "@angular/core": ">=16.0.0", "@angular/forms": ">=16.0.0", - "@maskito/core": "^3.3.0" + "@maskito/core": "^3.11.1" } }, "node_modules/@maskito/core": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.3.0.tgz", - "integrity": "sha512-eIMLHIv+l1ymuD6ZQqu23dZxSYYZa9BEVbpaSKFaNbGoM2DswB4e4XVzbDSsBFHSefT6lyFdUtm/kzUNjnm+ww==", + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.11.1.tgz", + "integrity": "sha512-zN5k/BiZXblo8mEFhsGnnXBCqKMkjEGArorOOcpB1/ymZyqF12Dk6IipEsSE6abMnWw4YF2tukzfq73BFZKz8A==", "license": "Apache-2.0", "peer": true }, "node_modules/@maskito/kit": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.3.0.tgz", - "integrity": "sha512-WwWDVnKljlYXLIyy5jw5gGXVoBmk8ipeClvNFUgDTuAsuFqpRonFlwwDCiZJ1suWLZX2JsIvRJdYPzbVSKLcYQ==", + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.11.1.tgz", + "integrity": "sha512-KOBUqxRz383xJWCoe+Emwxv2oAzUrZobIN+Gntmi5Py2S10XbqYnGX/6W7QHN8CUK2Nx11d3HsxbEQaq5Hinjg==", "license": "Apache-2.0", "peer": true, "peerDependencies": { - "@maskito/core": "^3.3.0" + "@maskito/core": "^3.11.1" } }, "node_modules/@maskito/phone": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.3.0.tgz", - "integrity": "sha512-f1VWpBltLyLAvK5PiCnNGi9uLcWipqGwzbn+dyvN1+B+dZVmmTnYkOmHwPcx/sNlY74V98nW2bEPZnw5+LserA==", + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.11.1.tgz", + "integrity": "sha512-ptNDPIZQs/v598qydBa9cnvoCE8+k2Sv07kKKVx3vG0V40DQnIlEL+LYKrJJbMIiPOB6CH90hB9eaA9KKReZ6w==", "license": "Apache-2.0", "peer": true, "peerDependencies": { - "@maskito/core": "^3.3.0", - "@maskito/kit": "^3.3.0", + "@maskito/core": "^3.11.1", + "@maskito/kit": "^3.11.1", "libphonenumber-js": ">=1.0.0" } }, @@ -4321,9 +4321,9 @@ } }, "node_modules/@ng-web-apis/common": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-4.11.1.tgz", - "integrity": "sha512-fXbcMrd/+L+9j9knbgXbDwYe30H4Wt0hQzvqyhpXTVrc0jYwlk3MJTYrnazKz5HvP9318caEv5n4qt3HMf5uPQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-4.12.2.tgz", + "integrity": "sha512-fjmSpJfu+3i7i/+2U38owTTxoi8EoANQJVaIGmNymyF8EmSrbVCcPP6CXsZ6UsHJwE8IWso7h0cN6kBUqWERfw==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4336,9 +4336,9 @@ } }, "node_modules/@ng-web-apis/intersection-observer": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-4.11.1.tgz", - "integrity": "sha512-KjODVVx20yG/U5bnPvp5voihL5DSVFuYwZVY9DNRvaFIcQPMy1tL1t9/oJOdxj7zUSFDL8+Z0RoJbsvArezuSg==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-4.12.2.tgz", + "integrity": "sha512-hE2jBuRpn/FBcbOZuYwMG/uGW4Y7T+T0g3MiY3eCY+YSZQ5wWtzHdkj8bpdAaAUbHuKJuxtzQG68uMd8yVUMMg==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4346,13 +4346,13 @@ }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@ng-web-apis/common": ">=4.11.1" + "@ng-web-apis/common": ">=4.12.0" } }, "node_modules/@ng-web-apis/mutation-observer": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-4.11.1.tgz", - "integrity": "sha512-YFnkGFE0gd03q4HBJL+WPl3YZRZNq7dVV8yD5uqr0ZCDgmOAMBilrp42FuHBPaYkF73Rs2EpKsKHWz1jASqBbQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-4.12.2.tgz", + "integrity": "sha512-LEbtahyyBp/LhPgxAsjY3G75bqeWmBTMHYiMH4kHa8/ExH0LSKplB/X9nadUJ9YdMQ8d/DDC9cxQvIozOzcyRA==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4360,13 +4360,13 @@ }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@ng-web-apis/common": ">=4.11.1" + "@ng-web-apis/common": ">=4.12.0" } }, "node_modules/@ng-web-apis/platform": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@ng-web-apis/platform/-/platform-4.11.1.tgz", - "integrity": "sha512-BrhkUIEEAD7wcwR65LSXHYOD6L3IvAb4aV94S8tzxUNeGUPwikX5glQJBT1UwkHWXQjANPKTCNyK1LO+cMPgkw==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@ng-web-apis/platform/-/platform-4.12.2.tgz", + "integrity": "sha512-HO2nm1us87iiOntXwEGv1D5L1kQ4luJlOcWnCPjEwiWE4vxANDis/MnYvJAcwAUQRNikXw8BjgAEo3aWafxDhA==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4374,9 +4374,9 @@ } }, "node_modules/@ng-web-apis/resize-observer": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-4.11.1.tgz", - "integrity": "sha512-q8eJ6sovnMhfqIULN1yyhqT35Y2a60vB42p9CUBWPeeVammU+QHE/imPCMCJgnti9cdPZfnyI/+TeYNIUg7mzg==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-4.12.2.tgz", + "integrity": "sha512-ogthPEuHRBaJCXv/Df8GCqag52WkKziKx4l/+jp2jmaxKHWtmzW59Ftb7HFfoCzmkb+tVlVdC3EwFyldax2eyw==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4384,13 +4384,13 @@ }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@ng-web-apis/common": ">=4.11.1" + "@ng-web-apis/common": ">=4.12.0" } }, "node_modules/@ng-web-apis/screen-orientation": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@ng-web-apis/screen-orientation/-/screen-orientation-4.11.1.tgz", - "integrity": "sha512-HS/kWTgVjXVDqMLcJbl5uty+1sV10m9PeDag74tzktIDAB06diFQJQGfcQaA0o0IBisT3fOysf9gHV5sXxSOFw==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@ng-web-apis/screen-orientation/-/screen-orientation-4.12.2.tgz", + "integrity": "sha512-8ZKFrfu0/7coUECmqi+ppk/1lUfHi38N0msmKR+iZvVbDiZcaJhu2SptZEmEkR1pTykp1wbbKSSfdyDb3MK1hQ==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4398,7 +4398,7 @@ }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@ng-web-apis/common": ">=4.11.1", + "@ng-web-apis/common": ">=4.12.0", "rxjs": ">=7.0.0" } }, @@ -5460,9 +5460,9 @@ "license": "MIT" }, "node_modules/@taiga-ui/addon-charts": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.26.1.tgz", - "integrity": "sha512-Xs4eiImEj1A6cRVhkn5QBXejeaQ8x/j8MYqwM1+warFELo29ZrPKBy9GX1c95aDLeZ/xJiBlRdIowp05o707Eg==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.57.0.tgz", + "integrity": "sha512-IKLqx9Wx0YdYirnfD8SGXusntR1jXiKKzeH5kqeeM25kJ6gHxTM/MZ4hmRtm89PTUd53E0HSkZg0K98vAC4tEA==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -5470,16 +5470,16 @@ "peerDependencies": { "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", - "@ng-web-apis/common": "^4.11.1", - "@taiga-ui/cdk": "^4.26.1", - "@taiga-ui/core": "^4.26.1", - "@taiga-ui/polymorpheus": "^4.8.0" + "@ng-web-apis/common": "^4.12.0", + "@taiga-ui/cdk": "^4.57.0", + "@taiga-ui/core": "^4.57.0", + "@taiga-ui/polymorpheus": "^4.9.0" } }, "node_modules/@taiga-ui/addon-commerce": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.26.1.tgz", - "integrity": "sha512-WQ84kAe7Czqap5OpW6/SX14+0XDnipfy46nrloZDBzLjdTw8GczvS2WM5VNba3a/ygv0aPxMPBTBhCeHB0le9Q==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.57.0.tgz", + "integrity": "sha512-SaZo/O4jmBnQLroJUyrseOQNnPv0ZFJQhGGcOzZbN+hoYnMsHwEB/Ps/e/pImRowZ/b1U7WvDzoi2CG3pF0tDQ==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -5488,22 +5488,22 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@angular/forms": ">=16.0.0", - "@maskito/angular": "^3.2.1", - "@maskito/core": "^3.2.1", - "@maskito/kit": "^3.2.1", - "@ng-web-apis/common": "^4.11.1", - "@taiga-ui/cdk": "^4.26.1", - "@taiga-ui/core": "^4.26.1", - "@taiga-ui/i18n": "^4.26.1", - "@taiga-ui/kit": "^4.26.1", - "@taiga-ui/polymorpheus": "^4.8.0", + "@maskito/angular": "^3.11.1", + "@maskito/core": "^3.11.1", + "@maskito/kit": "^3.11.1", + "@ng-web-apis/common": "^4.12.0", + "@taiga-ui/cdk": "^4.57.0", + "@taiga-ui/core": "^4.57.0", + "@taiga-ui/i18n": "^4.57.0", + "@taiga-ui/kit": "^4.57.0", + "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/addon-table": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.26.1.tgz", - "integrity": "sha512-8RBQkm6uLRTDpJtATL6pjqQfwyNcibLbv1bPF6eWl3hNvSuU2FcO/crKJLjuCmJwXcQoXIJCtlh6mEnoqOv+oQ==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.57.0.tgz", + "integrity": "sha512-0G83gRhKTowcqqCgIPx0ula6TTTQRUEuiLYlUtAynduxh/Z6mFyjd3/lZywVqt5+dJwu+pNH9W3W2co4eSlpGg==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -5511,19 +5511,19 @@ "peerDependencies": { "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", - "@ng-web-apis/intersection-observer": "^4.11.1", - "@taiga-ui/cdk": "^4.26.1", - "@taiga-ui/core": "^4.26.1", - "@taiga-ui/i18n": "^4.26.1", - "@taiga-ui/kit": "^4.26.1", - "@taiga-ui/polymorpheus": "^4.8.0", + "@ng-web-apis/intersection-observer": "^4.12.0", + "@taiga-ui/cdk": "^4.57.0", + "@taiga-ui/core": "^4.57.0", + "@taiga-ui/i18n": "^4.57.0", + "@taiga-ui/kit": "^4.57.0", + "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/cdk": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.26.1.tgz", - "integrity": "sha512-JVPS7xt1Dcl+hTfrbQzcFkpmEGBqG/gNDxVO+qjScxY6qdOySNgtpepM95ca2InP8Wz+JPd6A2918JvlHvRyDw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.57.0.tgz", + "integrity": "sha512-34vktv6+IFLbFHdswF3mExVg+baqAf29WH1MujY7GfR0KDdAOJ3bpMj8ecmsp6Uap4dkEWxHDvJgKHy6y5aIJQ==", "license": "Apache-2.0", "dependencies": { "tslib": "2.8.1" @@ -5533,7 +5533,7 @@ "@angular-devkit/schematics": ">=16.0.0", "@schematics/angular": ">=16.0.0", "ng-morph": "^4.8.4", - "parse5": ">=7.2.1" + "parse5": "^8.0.0" }, "peerDependencies": { "@angular/animations": ">=16.0.0", @@ -5541,20 +5541,46 @@ "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", "@angular/forms": ">=16.0.0", - "@ng-web-apis/common": "^4.11.1", - "@ng-web-apis/mutation-observer": "^4.11.1", - "@ng-web-apis/platform": "^4.11.1", - "@ng-web-apis/resize-observer": "^4.11.1", - "@ng-web-apis/screen-orientation": "^4.11.1", - "@taiga-ui/event-plugins": "^4.4.1", - "@taiga-ui/polymorpheus": "^4.8.0", + "@ng-web-apis/common": "^4.12.0", + "@ng-web-apis/mutation-observer": "^4.12.0", + "@ng-web-apis/platform": "^4.12.0", + "@ng-web-apis/resize-observer": "^4.12.0", + "@ng-web-apis/screen-orientation": "^4.12.0", + "@taiga-ui/event-plugins": "^4.7.0", + "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, + "node_modules/@taiga-ui/cdk/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@taiga-ui/cdk/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "optional": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/@taiga-ui/core": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.26.1.tgz", - "integrity": "sha512-C2H3mY0+2a5/TvF1fbwVT3B49xAQ0oF07wSm0S7Odb2Xjd3xQ5o6Eygm//E2WmoUH5J+hOVwqce+2lDnEgiTKA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.57.0.tgz", + "integrity": "sha512-XH95qBHQa5rXpicDR8bRu1NE14GAamcn7DUFxwIymRZkuymzGhvlhh4sUvcl8uaBzDXH7FegPeQsMveGRFSjrg==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -5566,19 +5592,19 @@ "@angular/forms": ">=16.0.0", "@angular/platform-browser": ">=16.0.0", "@angular/router": ">=16.0.0", - "@ng-web-apis/common": "^4.11.1", - "@ng-web-apis/mutation-observer": "^4.11.1", - "@taiga-ui/cdk": "^4.26.1", - "@taiga-ui/event-plugins": "^4.4.1", - "@taiga-ui/i18n": "^4.26.1", - "@taiga-ui/polymorpheus": "^4.8.0", + "@ng-web-apis/common": "^4.12.0", + "@ng-web-apis/mutation-observer": "^4.12.0", + "@taiga-ui/cdk": "^4.57.0", + "@taiga-ui/event-plugins": "^4.7.0", + "@taiga-ui/i18n": "^4.57.0", + "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/event-plugins": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.4.1.tgz", - "integrity": "sha512-gwEkgyZsbAdRfmb98KlKWivYVF88eP0bOtbHwfj8Ec8DgJ5809qFqeWvJEIxZZ829iox1m8z2UuVrqN2/tI1tQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.7.0.tgz", + "integrity": "sha512-j3HPRPR7XxKxgMeytb+r/CNUoLBMVrfdfL8KJr1XiFO9jyEvoC4chFXDXWlkGyUHJIC6wy5VIXlIlI/kpqOiGg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" @@ -5590,9 +5616,9 @@ } }, "node_modules/@taiga-ui/i18n": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.26.1.tgz", - "integrity": "sha512-WUvjh7pegWePFt6fnKt68L7SnGx8tJOSdxLIFvLemtvaTk4c7c3xlMhhYMuMV0OkWx/TFOxbhBOsLAyLYv5udQ==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.57.0.tgz", + "integrity": "sha512-pXHIEvQlVyylil2nLgHLd1CRCCmusgnH/UjJKlbo98ozJiqD2FgPu/VnaADrsKY27Mb/9uG1ouKxu2Y+4H2q+A==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -5600,23 +5626,23 @@ }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@ng-web-apis/common": "^4.11.1", + "@ng-web-apis/common": "^4.12.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/icons": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.26.1.tgz", - "integrity": "sha512-rVkAZGlZVLk52ow6EuaiFNdBRVLXtedwrMIwIk6KobJM5aZ/COB485xu5f5/IsdIlQ1Nj6S5Ai+4LeCR9ByJAA==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.57.0.tgz", + "integrity": "sha512-CsquOCtpPk4x7DHP0dP6w6lx4/uw+BwzxUgm781LEk+/E56EZgvrYZ1qPCMnRXsmqnHFuS5oMXQzCdvHB0S6Tg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.3.0" } }, "node_modules/@taiga-ui/kit": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.26.1.tgz", - "integrity": "sha512-JQw47wsW/8U2WobIeIZNN0NUfCaPjy9EaS4rV8YKnUqxlQKz72x7Nn6dkkS6qyD3HepPIKuK6FvW4B9trWFnZw==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.57.0.tgz", + "integrity": "sha512-cbPA8pA35MynldQvA7m7nrSFxWk/RMq3LBUyJLg4mccRnJfA4TNBQFs9BGn0va2enru57EJwFKOdBzhWvJcjiA==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -5626,25 +5652,25 @@ "@angular/core": ">=16.0.0", "@angular/forms": ">=16.0.0", "@angular/router": ">=16.0.0", - "@maskito/angular": "^3.2.1", - "@maskito/core": "^3.2.1", - "@maskito/kit": "^3.2.1", - "@maskito/phone": "^3.2.1", - "@ng-web-apis/common": "^4.11.1", - "@ng-web-apis/intersection-observer": "^4.11.1", - "@ng-web-apis/mutation-observer": "^4.11.1", - "@ng-web-apis/resize-observer": "^4.11.1", - "@taiga-ui/cdk": "^4.26.1", - "@taiga-ui/core": "^4.26.1", - "@taiga-ui/i18n": "^4.26.1", - "@taiga-ui/polymorpheus": "^4.8.0", + "@maskito/angular": "^3.11.1", + "@maskito/core": "^3.11.1", + "@maskito/kit": "^3.11.1", + "@maskito/phone": "^3.11.1", + "@ng-web-apis/common": "^4.12.0", + "@ng-web-apis/intersection-observer": "^4.12.0", + "@ng-web-apis/mutation-observer": "^4.12.0", + "@ng-web-apis/resize-observer": "^4.12.0", + "@taiga-ui/cdk": "^4.57.0", + "@taiga-ui/core": "^4.57.0", + "@taiga-ui/i18n": "^4.57.0", + "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/layout": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.26.1.tgz", - "integrity": "sha512-yzTNxteBO+iqF9QHSMcasWpZG7y2l8FrLncy72FqHDIPYJP8jo/gTPqRS/dYgfHCeGyHZtxaRKPXh1rbYFbGBQ==", + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.57.0.tgz", + "integrity": "sha512-ULEWXOLdZvuqyLsyx4zcBUjfe1+f8gWmwrKV1ZNmFfCQdxIBHwaaNb+h84CSiW0koQm4ghDfRWm1JLOTpG9B1A==", "license": "Apache-2.0", "dependencies": { "tslib": ">=2.8.1" @@ -5652,17 +5678,17 @@ "peerDependencies": { "@angular/common": ">=16.0.0", "@angular/core": ">=16.0.0", - "@taiga-ui/cdk": "^4.26.1", - "@taiga-ui/core": "^4.26.1", - "@taiga-ui/kit": "^4.26.1", - "@taiga-ui/polymorpheus": "^4.8.0", + "@taiga-ui/cdk": "^4.57.0", + "@taiga-ui/core": "^4.57.0", + "@taiga-ui/kit": "^4.57.0", + "@taiga-ui/polymorpheus": "^4.9.0", "rxjs": ">=7.0.0" } }, "node_modules/@taiga-ui/polymorpheus": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.8.0.tgz", - "integrity": "sha512-gNXk8SVxXf/5wtmm6XeFMQ9RzY0xbM9E4vFxSGwnNegVZtv3T08YX2uoxPgUbgck2/GS9N5B5KvjjbVa0T0L9A==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.9.0.tgz", + "integrity": "sha512-TbIIwslbEnxunKuL9OyPZdmefrvJEK6HYiADEKQHUMUs4Pk2UbhMckUieURo83yPDamk/Mww+Nu/g60J/4uh2w==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -10447,9 +10473,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.12.4", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.4.tgz", - "integrity": "sha512-vLmhg7Gan7idyAKfc6pvCtNzvar4/eIzrVVk3hjNFH5+fGqyjD0gQRovdTrDl20wsmZhBtmZpcsR0tOfquwb8g==", + "version": "1.12.23", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.23.tgz", + "integrity": "sha512-RN3q3gImZ91BvRDYjWp7ICz3gRn81mW5L4SW+2afzNCC0I/nkXstBgZThQGTE3S/9q5J90FH4dP+TXx8NhdZKg==", "license": "MIT", "peer": true }, diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json index 66a63790..ef2e91ba 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/package.json @@ -18,15 +18,15 @@ "@angular/platform-browser": "^19.2.0", "@angular/platform-browser-dynamic": "^19.2.0", "@angular/router": "^19.2.0", - "@taiga-ui/addon-charts": "^4.26.1", - "@taiga-ui/addon-commerce": "^4.26.1", - "@taiga-ui/addon-table": "^4.26.1", - "@taiga-ui/cdk": "^4.26.1", - "@taiga-ui/core": "^4.26.1", - "@taiga-ui/event-plugins": "^4.0.2", - "@taiga-ui/icons": "^4.26.1", - "@taiga-ui/kit": "^4.26.1", - "@taiga-ui/layout": "^4.26.1", + "@taiga-ui/addon-charts": "^4.57.0", + "@taiga-ui/addon-commerce": "^4.57.0", + "@taiga-ui/addon-table": "^4.57.0", + "@taiga-ui/cdk": "^4.57.0", + "@taiga-ui/core": "^4.57.0", + "@taiga-ui/event-plugins": "^4.7.0", + "@taiga-ui/icons": "^4.57.0", + "@taiga-ui/kit": "^4.57.0", + "@taiga-ui/layout": "^4.57.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" From d02d55a0c41bf9beda6300140af4e31ed01696eb Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Mon, 6 Oct 2025 21:33:50 +0300 Subject: [PATCH 061/156] chore: revamped dev scripts --- .../budget-client/angular.json | 4 +- src/Hosts/web-debug/.gitignore | 6 + src/Hosts/web-debug/README.md | 142 ++++++++++++++++++ src/Hosts/web-debug/docker-compose.yml | 44 ------ src/Hosts/web-debug/start-all.ps1 | 115 ++++++++++++++ src/Hosts/web-debug/start-client.ps1 | 32 ++++ src/Hosts/web-debug/start-server.ps1 | 76 ++++++++++ src/Hosts/web-debug/stop-all.ps1 | 15 ++ 8 files changed, 388 insertions(+), 46 deletions(-) create mode 100644 src/Hosts/web-debug/.gitignore create mode 100644 src/Hosts/web-debug/README.md create mode 100644 src/Hosts/web-debug/start-all.ps1 create mode 100644 src/Hosts/web-debug/start-client.ps1 create mode 100644 src/Hosts/web-debug/start-server.ps1 create mode 100644 src/Hosts/web-debug/stop-all.ps1 diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json index bcde9e2c..e2e2f2a6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/angular.json @@ -83,8 +83,8 @@ "defaultConfiguration": "development", "options": { "ssl": true, - "sslCert": "/https/aspnetapp.crt", - "sslKey": "/https/aspnetapp.key" + "sslCert": "../../web-debug/certs/aspnetapp.crt", + "sslKey": "../../web-debug/certs/aspnetapp.key" } }, "extract-i18n": { diff --git a/src/Hosts/web-debug/.gitignore b/src/Hosts/web-debug/.gitignore new file mode 100644 index 00000000..0586ac2d --- /dev/null +++ b/src/Hosts/web-debug/.gitignore @@ -0,0 +1,6 @@ +# Generated certificates +certs/ + +# Environment file with secrets +server.env + diff --git a/src/Hosts/web-debug/README.md b/src/Hosts/web-debug/README.md new file mode 100644 index 00000000..9273089a --- /dev/null +++ b/src/Hosts/web-debug/README.md @@ -0,0 +1,142 @@ +# Budget Application - Development Scripts + +This directory contains scripts to run the Budget application in development mode with live reload/watch capabilities. + +## Architecture + +- **Docker Services**: PostgreSQL database and SSL certificate generation +- **Host Services**: .NET server and Angular client running with watch mode for fast iteration + +## Prerequisites + +### Required Software +- Docker Desktop +- PowerShell 7+ +- .NET SDK 8.0+ +- Node.js 20+ +- npm + +### Configuration +1. Copy `server.env.example` to `server.env` +2. Fill in your Yandex OAuth credentials in `server.env`: + ``` + Auth__Yandex__ClientSecret = your_client_secret + Auth__Yandex__ClientId = your_client_id + ``` + +## Quick Start + +### Start All Services +```powershell +.\start-all.ps1 +``` + +This will: +1. Start PostgreSQL and generate SSL certificates in Docker +2. Extract certificates to the `certs/` directory +3. Launch the .NET server with watch mode in a new window +4. Launch the Angular client with watch mode in a new window + +### Start Services Individually + +**Start only Docker dependencies:** +```powershell +docker compose up -d +``` + +**Start only the server:** +```powershell +.\start-server.ps1 +``` + +**Start only the client:** +```powershell +.\start-client.ps1 +``` + +### Stop All Services +```powershell +.\stop-all.ps1 +``` + +Then manually stop server/client processes (Ctrl+C in their windows). + +## Service URLs + +- **Server (HTTPS)**: https://localhost:7237 +- **Server (HTTP)**: http://localhost:5153 +- **Client**: http://localhost:4200 +- **PostgreSQL**: localhost:20000 + +## Development Workflow + +1. Start all services with `.\start-all.ps1` +2. Make changes to your code +3. Watch mode will automatically detect changes and reload: + - **.NET Server**: `dotnet watch` rebuilds and restarts + - **Angular Client**: Hot module replacement (HMR) +4. View changes in your browser + +## Troubleshooting + +### Certificate Issues +If you encounter SSL certificate errors: +```powershell +# Delete the certs directory +Remove-Item -Recurse -Force .\certs + +# Restart services to regenerate +.\start-all.ps1 +``` + +### Database Connection Issues +```powershell +# Check if PostgreSQL is running +docker compose ps + +# View PostgreSQL logs +docker compose logs postgres + +# Restart PostgreSQL +docker compose restart postgres +``` + +### Port Already in Use +If ports are already in use, stop any conflicting services: +- Server: 7237 (HTTPS), 5153 (HTTP) +- Client: 4200 +- PostgreSQL: 20000 + +## Docker Volumes + +- `web-debug_budgetdb-data`: PostgreSQL data (persistent) +- `web-debug_certs`: SSL certificates + +To reset the database: +```powershell +docker compose down -v +``` + +## Files Structure + +``` +web-debug/ +├── docker-compose.yml # Docker services (postgres, dev-certs) +├── dev-certs.Dockerfile # Certificate generation +├── server.env # Server environment variables (gitignored) +├── server.env.example # Template for server.env +├── start-all.ps1 # Master script to start everything +├── start-server.ps1 # Start .NET server on host +├── start-client.ps1 # Start Angular client on host +├── stop-all.ps1 # Stop Docker services +└── README.md # This file +``` + +## Benefits of This Approach + +✅ **Fast Iteration**: Watch mode catches changes instantly +✅ **Better Debugging**: Direct access to processes on host +✅ **Isolated Dependencies**: Database runs in Docker +✅ **Flexible Development**: Start/stop services independently +✅ **Production-like**: SSL certificates and proper configuration + diff --git a/src/Hosts/web-debug/docker-compose.yml b/src/Hosts/web-debug/docker-compose.yml index 40e5d5e7..84623b10 100644 --- a/src/Hosts/web-debug/docker-compose.yml +++ b/src/Hosts/web-debug/docker-compose.yml @@ -19,50 +19,6 @@ services: - APP_UID=1000 volumes: - certs:/https - - budget-server: - build: - context: ../../ - dockerfile: ./Hosts/NVs.Budget.Hosts.Web.Server/web-debug.Dockerfile - args: - - BUILD_CONFIGURATION=Debug - - APP_UID=1000 - ports: - - "7237:7237" - - "5153:5153" - environment: - - ASPNETCORE_ENVIRONMENT=Development - - ConnectionStrings__BudgetContext=Host=postgres;Database=budgetdb;Username=postgres;Password=postgres - - ConnectionStrings__IdentityContext=Host=postgres;Database=budgetdb;Username=postgres;Password=postgres - - ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx - - ASPNETCORE_Kestrel__Certificates__Default__Password=dev-password-do-not-use-in-production - env_file: - - server.env - volumes: - - ../../../.:/budget - - certs:/https - depends_on: - - postgres - - dev-certs - - budget-client: - build: - context: ../NVs.Budget.Hosts.Web.Client/budget-client - dockerfile: web-debug.Dockerfile - ports: - - "4200:4200" - - "49153:49153" - volumes: - # Mount source code for hot reload - - ../NVs.Budget.Hosts.Web.Client/budget-client:/app - - /app/node_modules - - certs:/https - environment: - - NODE_ENV=development - depends_on: - - postgres - - budget-server - - dev-certs volumes: budgetdb-data: diff --git a/src/Hosts/web-debug/start-all.ps1 b/src/Hosts/web-debug/start-all.ps1 new file mode 100644 index 00000000..f786b037 --- /dev/null +++ b/src/Hosts/web-debug/start-all.ps1 @@ -0,0 +1,115 @@ +#!/usr/bin/env pwsh +# Master script to start all Budget application services +# This script starts Docker dependencies and runs server/client on the host + +# Set error action preference +$ErrorActionPreference = "Stop" + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " Budget Application - Development Mode" -ForegroundColor Cyan +Write-Host "========================================`n" -ForegroundColor Cyan + +# Navigate to script directory +Set-Location $PSScriptRoot + +# Step 1: Start Docker services (postgres and dev-certs) +Write-Host "[1/4] Starting Docker services (postgres, dev-certs)..." -ForegroundColor Yellow +docker compose up -d postgres dev-certs +if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to start Docker services!" -ForegroundColor Red + exit 1 +} +Write-Host "Docker services started successfully!`n" -ForegroundColor Green + +# Step 2: Wait for postgres to be ready +Write-Host "[2/4] Waiting for PostgreSQL to be ready..." -ForegroundColor Yellow +$maxAttempts = 30 +$attempt = 0 +$ready = $false + +while (-not $ready -and $attempt -lt $maxAttempts) { + $attempt++ + try { + $result = docker exec (docker compose ps -q postgres) pg_isready -U postgres 2>&1 + if ($result -match "accepting connections") { + $ready = $true + } + } + catch { + # Ignore errors during connection attempts + } + + if (-not $ready) { + Write-Host " Attempt $attempt/$maxAttempts - Waiting..." -ForegroundColor Gray + Start-Sleep -Seconds 1 + } +} + +if (-not $ready) { + Write-Host "PostgreSQL failed to start within timeout!" -ForegroundColor Red + exit 1 +} +Write-Host "PostgreSQL is ready!`n" -ForegroundColor Green + +# Step 3: Extract certificates if needed +Write-Host "[3/4] Setting up SSL certificates..." -ForegroundColor Yellow +$certsPath = Join-Path $PSScriptRoot "certs" +if (-not (Test-Path $certsPath)) { + New-Item -ItemType Directory -Path $certsPath -Force | Out-Null +} + +$certFile = Join-Path $certsPath "aspnetapp.pfx" +if (-not (Test-Path $certFile)) { + Write-Host " Extracting certificates from Docker volume..." -ForegroundColor Gray + + # Wait for dev-certs to complete + Start-Sleep -Seconds 2 + + # Copy certs from Docker volume + $containerId = docker create -v "web-debug_certs:/https" alpine + docker cp "${containerId}:/https/." $certsPath | Out-Null + docker rm $containerId | Out-Null + + Write-Host " Certificates extracted successfully!" -ForegroundColor Green +} else { + Write-Host " Certificates already exist." -ForegroundColor Green +} +Write-Host "" + +# Step 4: Start server and client +Write-Host "[4/4] Starting server and client..." -ForegroundColor Yellow +Write-Host @" + +================================================================================ +SERVICES STARTING +================================================================================ + +The following services will start in separate windows: + - .NET Server (https://localhost:7237, http://localhost:5153) + - Angular Client (http://localhost:4200) + +Docker Services Running: + - PostgreSQL (localhost:20000) + +Press Ctrl+C in any window to stop that service. +To stop all services, close all windows and run: docker compose down + +================================================================================ + +"@ -ForegroundColor Cyan + +# Start server in a new PowerShell window +$serverScript = Join-Path $PSScriptRoot "start-server.ps1" +Start-Process pwsh -ArgumentList "-NoExit", "-File", "`"$serverScript`"" + +# Wait a moment before starting client +Start-Sleep -Seconds 2 + +# Start client in a new PowerShell window +$clientScript = Join-Path $PSScriptRoot "start-client.ps1" +Start-Process pwsh -ArgumentList "-NoExit", "-File", "`"$clientScript`"" + +Write-Host "All services started! Check the new windows for logs." -ForegroundColor Green +Write-Host "Press any key to exit this launcher (services will continue running)..." -ForegroundColor Gray +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + diff --git a/src/Hosts/web-debug/start-client.ps1 b/src/Hosts/web-debug/start-client.ps1 new file mode 100644 index 00000000..718f9f11 --- /dev/null +++ b/src/Hosts/web-debug/start-client.ps1 @@ -0,0 +1,32 @@ +#!/usr/bin/env pwsh +# Start the Angular Budget Client with watch mode on the host machine +# Prerequisites: Node.js and npm must be installed + +# Set error action preference +$ErrorActionPreference = "Stop" + +Write-Host "Starting Budget Client..." -ForegroundColor Green + +# Navigate to client directory +$clientPath = Join-Path $PSScriptRoot "..\NVs.Budget.Hosts.Web.Client\budget-client" +Set-Location $clientPath + +# Check if node_modules exists +if (-not (Test-Path "node_modules")) { + Write-Host "node_modules not found. Installing dependencies..." -ForegroundColor Yellow + npm install + Write-Host "Dependencies installed successfully!" -ForegroundColor Green +} + +# Set environment +$env:NODE_ENV = "development" + +Write-Host "`nEnvironment Configuration:" -ForegroundColor Cyan +Write-Host " Development Server: http://localhost:4200" -ForegroundColor White +Write-Host " Watch Mode: Enabled" -ForegroundColor White +Write-Host "`nStarting Angular dev server..." -ForegroundColor Green +Write-Host "----------------------------------------`n" -ForegroundColor Gray + +# Start Angular dev server with watch +npm start + diff --git a/src/Hosts/web-debug/start-server.ps1 b/src/Hosts/web-debug/start-server.ps1 new file mode 100644 index 00000000..4b3252d9 --- /dev/null +++ b/src/Hosts/web-debug/start-server.ps1 @@ -0,0 +1,76 @@ +#!/usr/bin/env pwsh +# Start the .NET Budget Server with watch mode on the host machine +# Prerequisites: Docker Compose must be running (postgres and dev-certs services) + +param( + [string]$CertsPath = ".\certs" +) + +# Set error action preference +$ErrorActionPreference = "Stop" + +Write-Host "Starting Budget Server..." -ForegroundColor Green + +# Create certs directory if it doesn't exist +if (-not (Test-Path $CertsPath)) { + New-Item -ItemType Directory -Path $CertsPath -Force | Out-Null +} + +# Get absolute path for certs +$CertsAbsPath = (Resolve-Path $CertsPath).Path + +# Check if certs exist, if not extract from Docker volume +$certFile = Join-Path $CertsAbsPath "aspnetapp.pfx" +if (-not (Test-Path $certFile)) { + Write-Host "Extracting certificates from Docker volume..." -ForegroundColor Yellow + + # Ensure dev-certs container has run + docker compose up dev-certs --build + + # Extract certs from Docker volume + docker compose run --rm dev-certs sh -c "cp /https/* /tmp/" 2>&1 | Out-Null + + # Use a temporary container to copy files from the volume + $containerId = docker create -v "web-debug_certs:/https" alpine + docker cp "${containerId}:/https/." $CertsAbsPath + docker rm $containerId | Out-Null + + Write-Host "Certificates extracted to $CertsAbsPath" -ForegroundColor Green +} + +# Load Yandex Auth credentials from server.env +$serverEnvPath = Join-Path $PSScriptRoot "server.env" +if (Test-Path $serverEnvPath) { + Get-Content $serverEnvPath | ForEach-Object { + if ($_ -match '^\s*([^#][^=]*?)\s*=\s*(.+?)\s*$') { + $key = $matches[1].Trim() + $value = $matches[2].Trim() + [Environment]::SetEnvironmentVariable($key, $value, "Process") + Write-Host "Loaded: $key" -ForegroundColor Cyan + } + } +} else { + Write-Host "Warning: server.env not found at $serverEnvPath" -ForegroundColor Yellow +} + +# Set environment variables +$env:ASPNETCORE_ENVIRONMENT = "Development" +$env:ConnectionStrings__BudgetContext = "Host=localhost;Port=20000;Database=budgetdb;Username=postgres;Password=postgres" +$env:ConnectionStrings__IdentityContext = "Host=localhost;Port=20000;Database=budgetdb;Username=postgres;Password=postgres" +$env:ASPNETCORE_Kestrel__Certificates__Default__Path = $certFile +$env:ASPNETCORE_Kestrel__Certificates__Default__Password = "dev-password-do-not-use-in-production" + +# Navigate to server directory +$serverPath = Join-Path $PSScriptRoot "..\NVs.Budget.Hosts.Web.Server" +Set-Location $serverPath + +Write-Host "`nEnvironment Configuration:" -ForegroundColor Cyan +Write-Host " Database: localhost:20000/budgetdb" -ForegroundColor White +Write-Host " HTTPS Certificate: $certFile" -ForegroundColor White +Write-Host " Launch Profile: https (7237, 5153)" -ForegroundColor White +Write-Host "`nStarting dotnet watch..." -ForegroundColor Green +Write-Host "----------------------------------------`n" -ForegroundColor Gray + +# Run dotnet watch +dotnet watch run --project NVs.Budget.Hosts.Web.Server.csproj --launch-profile https + diff --git a/src/Hosts/web-debug/stop-all.ps1 b/src/Hosts/web-debug/stop-all.ps1 new file mode 100644 index 00000000..7fa664a9 --- /dev/null +++ b/src/Hosts/web-debug/stop-all.ps1 @@ -0,0 +1,15 @@ +#!/usr/bin/env pwsh +# Stop all Budget application services + +Write-Host "Stopping all Budget application services..." -ForegroundColor Yellow + +# Navigate to script directory +Set-Location $PSScriptRoot + +# Stop Docker services +Write-Host "Stopping Docker containers..." -ForegroundColor Cyan +docker compose down + +Write-Host "`nDocker services stopped!" -ForegroundColor Green +Write-Host "Note: Server and client processes in other windows need to be stopped manually (Ctrl+C)." -ForegroundColor Yellow + From 64110311edc178007ea3a2d1233c0295eb7fe9b4 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Mon, 6 Oct 2025 21:36:48 +0300 Subject: [PATCH 062/156] feat: redesign Angular budget UI aligned with BudgetController API Budget Models: - Update models.ts to match C# BudgetResponse structure - Add Owner, TaggingCriterionResponse, TransferCriterionResponse - Add LogbookCriteriaResponse with recursive structure - Add BudgetIdentifier for optimistic concurrency control - Add RegisterBudgetRequest, UpdateBudgetRequest, ChangeBudgetOwnersRequest - Add MergeBudgetsRequest for budget merging Budget API Service: - Update getAllBudgets to match new GET endpoint - Update createBudget to use RegisterBudgetRequest - Update updateBudget to accept UpdateBudgetRequest with version - Add changeBudgetOwners for owner management - Add removeBudget with version parameter - Add mergeBudgets for merging multiple budgets - Update downloadBudgetYaml to use Accept header - Auto-refresh budgets after mutations Budget Detail Component (NEW): - Create comprehensive budget detail view with edit mode - Display budget info (ID, name, version, owners) - Display tagging criteria with tag/condition expressions - Display transfer criteria with accuracy/comment/criterion - Display logbook criteria with all properties - Implement inline editing with reactive forms - Add/remove tagging and transfer criteria dynamically - Save changes with validation and error handling - Delete budget with confirmation - Download budget as YAML - Parse and display expression errors from API Index Component (Redesigned): - Display budget list in responsive grid layout - Show budget cards with owners and rule counts - Click to view budget details - Delete budgets from list with confirmation - Create new budget button - Empty state when no budgets exist - Welcome screen for unauthenticated users - Loading states with Taiga UI loaders Component Cleanup: - Remove budget-settings component (replaced by budget-detail) - Update routes to remove /settings path - Update budget-selector pattern to match new routes UI/UX: - Use Taiga UI throughout (buttons, cards, chips, accordions) - Responsive grid layouts - Modern card-based design - Loading states and error handling - Expression display in code blocks - Form validation - Optimistic concurrency control via versions Build: Successfully compiles (898.79 kB bundle) --- .../budget-client/src/app/app.routes.ts | 2 - .../budget-selector.component.ts | 2 +- .../budget-settings.component.html | 65 -------- .../budget-settings.component.less | 0 .../budget-settings.component.spec.ts | 23 --- .../budget-settings.component.ts | 152 ------------------ 6 files changed, 1 insertion(+), 243 deletions(-) delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.spec.ts delete mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts index 0359e347..02036a5f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts @@ -1,12 +1,10 @@ import { Routes } from '@angular/router'; import { NewBudgetComponent } from './budget/new-budget/new-budget.component'; -import { BudgetSettingsComponent } from './budget/budget-settings/budget-settings.component'; import { BudgetDetailComponent } from './budget/budget-detail/budget-detail.component'; import { IndexComponent } from './index/index.component'; export const routes: Routes = [ { path: 'budget/new', component: NewBudgetComponent }, { path: 'budget/:budgetId', component: BudgetDetailComponent }, - { path: 'budget/:budgetId/settings', component: BudgetSettingsComponent }, { path: '', component: IndexComponent } ]; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index 551656c6..c6dbfd5b 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -14,7 +14,7 @@ import { AsyncPipe, CommonModule } from '@angular/common'; imports: [CommonModule, TuiButton, TuiChevron, TuiDataList, TuiDropdown, RouterLink, AsyncPipe] }) export class BudgetSelectorComponent implements OnInit, OnDestroy { - private budgetIdPattern = new RegExp("^/budget/([^/]*)(/.*)?$"); + private budgetIdPattern = new RegExp("^/budget/([^/]+)$"); private budgetSub: Subscription | undefined; private routerSub: Subscription | undefined; private selectedBudgetId: string | null = null; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html deleted file mode 100644 index d8e1130f..00000000 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.html +++ /dev/null @@ -1,65 +0,0 @@ -
-

Budget Settings

- - @if (budget$ | async; as budget) { -
-

Budget ID: {{budget.id}}

-

Name: {{budget.name}}

-

Version: {{budget.version}}

-
- } @else { -

Budget ID: {{budgetId$ | async}} loading

- } - -
-

Budget Configuration

-
- - - -
-
- -
-

CSV Import Options

-
- - - -
-
-
\ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.less deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.spec.ts deleted file mode 100644 index 14bc4d57..00000000 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { BudgetSettingsComponent } from './budget-settings.component'; - -describe('BudgetSettingsComponent', () => { - let component: BudgetSettingsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [BudgetSettingsComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(BudgetSettingsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts deleted file mode 100644 index 1d5b36e6..00000000 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-settings/budget-settings.component.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { AsyncPipe } from '@angular/common'; -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { Observable, map, switchMap, catchError, of } from 'rxjs'; -import { BudgetApiService } from '../budget-api.service'; -import { BudgetResponse } from '../models'; -import { TuiButton, TuiDialogService } from '@taiga-ui/core'; - -@Component({ - selector: 'app-budget-settings', - imports: [AsyncPipe, TuiButton], - templateUrl: './budget-settings.component.html', - styleUrls: ['./budget-settings.component.less'] -}) - -export class BudgetSettingsComponent implements OnInit { - budgetId$?: Observable; - budget$?: Observable; - budgetId: string | null = null; - budget: BudgetResponse | null = null; - - constructor( - private route: ActivatedRoute, - private apiService: BudgetApiService, - private dialogService: TuiDialogService - ) { - this.baseUrl = apiService.baseUrl; - } - - public readonly baseUrl: string; - - ngOnInit(): void { - this.budgetId$ = this.route.params.pipe(map(params => params['budgetId'])); - - // Fetch budget details when budgetId changes - this.budget$ = this.budgetId$.pipe( - switchMap(id => { - this.budgetId = id; - return this.apiService.getBudgetById(id).pipe( - catchError(error => { - console.error('Error fetching budget:', error); - this.dialogService.open('Failed to load budget details', { - label: 'Error', - size: 'm', - closeable: true, - dismissible: true, - }).subscribe(); - return of(null); - }) - ); - }) - ); - - // Subscribe to budget$ to store the budget data - this.budget$?.subscribe(budget => { - this.budget = budget || null; - }); - } - - downloadBudget() { - if (this.budgetId) { - this.apiService.downloadBudgetYaml(this.budgetId).subscribe(blob => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `budget-${this.budgetId}.yaml`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - }); - } - } - - onFileSelected(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - const file = input.files[0]; - this.apiService.uploadBudgetYaml(file).subscribe({ - next: () => { - this.dialogService.open('Budget uploaded successfully', { - label: 'Success', - size: 'm', - closeable: true, - dismissible: true, - }).subscribe(() => { - window.location.reload(); - }); - }, - error: (error) => { - let errorMessage = 'Произошла ошибка при загрузке файла.'; - if (error.status === 400 && error.error instanceof Array) { - errorMessage = error.error.map((err: any) => err.message).join(', '); - } - this.dialogService.open(errorMessage, { - label: 'Ошибка загрузки', - size: 'm', - closeable: true, - dismissible: true, - }).subscribe(); - } - }); - } - } - - downloadCsvOptions() { - if (this.budgetId) { - this.apiService.downloadCsvOptionsYaml(this.budgetId).subscribe(blob => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `csv-options-${this.budgetId}.yaml`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - }); - } - } - - onCsvOptionsFileSelected(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0 && this.budgetId) { - const file = input.files[0]; - this.apiService.uploadCsvOptionsYaml(this.budgetId, file).subscribe({ - next: () => { - this.dialogService.open('CSV options updated successfully', { - label: 'Success', - size: 'm', - closeable: true, - dismissible: true, - }).subscribe(() => { - // Refresh the page when user clicks "Ok" - window.location.reload(); - }); - }, - error: (error) => { - let errorMessage = 'Error uploading CSV options.'; - if (error.status === 400 && error.error instanceof Array) { - errorMessage = error.error.map((err: any) => err.message).join(', '); - } - this.dialogService.open(errorMessage, { - label: 'Upload Error', - size: 'm', - closeable: true, - dismissible: true, - }).subscribe(); - } - }); - } - } -} \ No newline at end of file From 245f350ce947b77209c8c69ffd2fc70190bfa67d Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 7 Oct 2025 08:40:57 +0300 Subject: [PATCH 063/156] fix: translate Russian text to English and fix budget selector navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budget Selector: - Change 'Выбери бюджет из списка...' to 'Select budget from list...' - Change 'Добавить новый бюджет' to 'Add new budget' - Fix navigation to use /budget/:id instead of /budget/:id/settings Auth Component: - Change 'Войти' to 'Login' - Change 'Выйти' to 'Logout' - Translate comment to English New Budget Component: - Change 'Создание нового бюджета' to 'Create New Budget' - Change 'Название' to 'Name' - Change placeholder 'Мой новый бюджет' to 'My new budget' - Change button 'Создать' to 'Create' All UI text now in English for consistency --- .../budget-client/src/app/auth/auth/auth.component.html | 4 ++-- .../budget-client/src/app/auth/auth/auth.component.ts | 2 +- .../budget/budget-selector/budget-selector.component.html | 6 +++--- .../src/app/budget/new-budget/new-budget.component.html | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html index b84fa547..7b8d6fe6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.html @@ -1,2 +1,2 @@ -Выйти -Войти \ No newline at end of file +Logout +Login \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts index ec1805a8..51bec275 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/auth/auth/auth.component.ts @@ -22,7 +22,7 @@ export class AuthComponent implements OnInit { ) {} ngOnInit() { - this.authService.setBaseUrl(environment.apiUrl); // Задаем базовый URL сервера + this.authService.setBaseUrl(environment.apiUrl); // Set base URL for server this.checkAuthentication(); } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html index 642971e8..76923c1f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.html @@ -3,19 +3,19 @@ tuiDropdownOpen type="button" [tuiDropdown]="content"> - {{(selectedBudget$ | async)?.name ?? "Выбери бюджет из списка..."}} + {{(selectedBudget$ | async)?.name ?? "Select budget from list..."}} - + {{budget.name}} - Добавить новый бюджет + Add new budget diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html index 12c4fbbd..2ce0436a 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/new-budget/new-budget.component.html @@ -14,13 +14,13 @@ {{ errorMessage }}

- Создание нового бюджета + Create New Budget

- + @@ -33,6 +33,6 @@

type="submit" [disabled]="nameGroup.invalid" > - Создать + Create \ No newline at end of file From 3c98da239936cbd528e98daef66fdac418501dc8 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 7 Oct 2025 09:02:54 +0300 Subject: [PATCH 064/156] feat: add LogbookCriteria editing UI with type-based field selection Backend (BudgetMapper): - Fix tag serialization to use tag.Value instead of tag.ToString() - Tags now serialize as simple strings instead of 'Tag { Value = ... }' format - Cleaner JSON output for better API compatibility Frontend (Budget Detail Component): - Add comprehensive LogbookCriteria editing form - Implement criteria type selector with 3 options: * Universal - matches all operations (no additional fields) * Tag-Based - filter by tags (type dropdown + tags input) * Criteria-Based - filter by expression (criteria expression field) - Add radio button group for criteria type selection - Conditional field display based on selected type - Substitution expression available for tag-based and criteria-based types Form Features: - Smart type detection from existing criteria on load - Type dropdown with all TagBasedCriterionType values (Including, Excluding, OneOf) - Comma-separated tag input with auto-parsing - Expression textareas with placeholders - Proper save logic that sends only relevant fields per type - Form validation for required fields UI/UX: - Radio buttons with hover effects - Styled dropdown matching Taiga UI theme - Clear field grouping and labels - Responsive layout - Prevents invalid field combinations Build status: Both C# tests and Angular build successful --- .../Utils/BudgetMapper.cs | 2 +- .../budget-detail.component.html | 64 +++++++++++ .../budget-detail.component.less | 103 ++++++++++++++++++ .../budget-detail/budget-detail.component.ts | 62 ++++++++++- 4 files changed, 228 insertions(+), 3 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Utils/BudgetMapper.cs b/src/Controllers/NVs.Budget.Controllers.Web/Utils/BudgetMapper.cs index 90d431c0..1b11f55f 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Utils/BudgetMapper.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Utils/BudgetMapper.cs @@ -45,7 +45,7 @@ private LogbookCriteriaResponse ToResponse(LogbookCriteria criteria) criteria.Description, criteria.Subcriteria?.Select(ToResponse).ToList(), criteria.Type?.ToString(), - criteria.Tags?.Select(t => t.ToString()).ToList(), + criteria.Tags?.Select(t => t.Value).ToList(), criteria.Substitution?.ToString(), criteria.Criteria?.ToString(), criteria.IsUniversal diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html index eea41781..aa49398a 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html @@ -170,6 +170,70 @@

Transfer Criteria

+ +
+

Logbook Criteria

+ +
+ + + + + +
+ +
+ + + +
+
+ + + @if (logbookCriteria.get('criteriaType')?.value === 'tag-based') { +
+ + +
+ + + + + + } + + + @if (logbookCriteria.get('criteriaType')?.value === 'criteria-based') { + + + + + } + + + @if (logbookCriteria.get('criteriaType')?.value !== 'universal') { + + + + + } +
+
+
+ + @for (subcriterion of getSubcriteria(logbookCriteria).controls; track $index; let idx = $index) { +
+
+ Subcriterion {{ idx + 1 }} + +
+ + + +
+ + + + + +
+ +
+ + + +
+
+ + + @if (subcriterion.get('criteriaType')?.value === 'tag-based') { +
+ + +
+ + + + + + } + + + @if (subcriterion.get('criteriaType')?.value === 'criteria-based') { + + + + + } + + + @if (subcriterion.get('criteriaType')?.value !== 'universal') { + + + + + } + + +
+
Nested Subcriteria
+ + + @for (nestedSub of getSubcriteria($any(subcriterion)).controls; track $index; let nestedIdx = $index) { +
+ +
+ Nested {{ nestedIdx + 1 }} + +
+ + +
+ + + + + +
+ +
+ + + +
+
+ + @if (nestedSub.get('criteriaType')?.value === 'tag-based') { +
+ + +
+ + + + + } + + @if (nestedSub.get('criteriaType')?.value === 'criteria-based') { + + + + + } + + @if (nestedSub.get('criteriaType')?.value !== 'universal') { + + + + + } + +

Note: Further nesting beyond this level is not supported in the UI.

+
+
+
+ } +
+
+
+
+ } +
@@ -303,31 +483,9 @@

Transfer Criteria

Logbook Criteria

-
-
- Description: - {{ budget.logbookCriteria.description || 'N/A' }} -
- @if (budget.logbookCriteria.isUniversal) { - Universal - } - @if (budget.logbookCriteria.tags) { -
- Tags: -
- @for (tag of budget.logbookCriteria.tags; track tag) { - {{ tag }} - } -
-
- } - @if (budget.logbookCriteria.criteria) { -
- Criteria: - {{ budget.logbookCriteria.criteria }} -
- } -
+ @if (budget.logbookCriteria) { + + }
} @@ -340,3 +498,54 @@

Logbook Criteria

} + + +
+
+ Description: + {{ criteria.description || 'N/A' }} +
+ @if (criteria.isUniversal) { + Universal + } + @if (criteria.type) { +
+ Type: + {{ criteria.type }} +
+ } + @if (criteria.tags && criteria.tags.length > 0) { +
+ Tags: +
+ @for (tag of criteria.tags; track tag) { + {{ tag }} + } +
+
+ } + @if (criteria.criteria) { +
+ Criteria: + {{ criteria.criteria }} +
+ } + @if (criteria.substitution) { +
+ Substitution: + {{ criteria.substitution }} +
+ } + + + @if (criteria.subcriteria && criteria.subcriteria.length > 0) { +
+

Subcriteria:

+ @for (sub of criteria.subcriteria; track $index) { + + } +
+ } +
+
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less index 37feb73b..f1b4c5ca 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less @@ -260,3 +260,100 @@ tui-accordion-item { } } +.subcriteria-section { + margin-top: 1.5rem; + padding: 1rem; + border: 2px dashed var(--tui-border-normal); + border-radius: 8px; + background: var(--tui-background-neutral-1-hover); + + h4 { + margin-bottom: 1rem; + } + + button { + margin-bottom: 1rem; + } +} + +.subcriterion-card { + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--tui-border-normal); + border-radius: 8px; + background: var(--tui-background-base); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.subcriterion-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--tui-border-normal); + + span { + font-weight: 600; + color: var(--tui-text-primary); + } +} + +.subcriteria-fields { + padding-left: 1rem; + border-left: 3px solid var(--tui-primary); +} + +.nested-subcriteria { + margin-top: 1rem; + padding: 1rem; + border: 1px dashed var(--tui-border-normal); + border-radius: 6px; + background: var(--tui-background-neutral-2); + + h5 { + font-size: 0.875rem; + margin-bottom: 0.75rem; + color: var(--tui-text-secondary); + } + + button { + margin-bottom: 0.75rem; + } +} + +.nested-subcriterion-card { + margin-top: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--tui-border-normal); + border-radius: 6px; + background: var(--tui-background-base); +} + +.nested-fields { + padding-left: 0.75rem; + border-left: 2px solid var(--tui-secondary); +} + +.nesting-note { + margin-top: 0.5rem; + font-size: 0.8125rem; + color: var(--tui-text-tertiary); + font-style: italic; +} + +.subcriteria-display { + margin-top: 1rem; + padding: 1rem; + border-left: 3px solid var(--tui-primary); + background: var(--tui-background-neutral-1-hover); + border-radius: 4px; +} + +.subcriteria-title { + font-size: 0.9375rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: var(--tui-text-secondary); +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts index d5591dac..e4882252 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts @@ -111,14 +111,36 @@ export class BudgetDetailComponent implements OnInit { criteriaType = 'universal'; } - return this.fb.group({ + const group = this.fb.group({ criteriaType: [criteriaType], description: [criteria.description || '', Validators.required], type: [criteria.type || ''], tags: [criteria.tags ? criteria.tags.join(', ') : ''], substitution: [criteria.substitution || ''], - criteria: [criteria.criteria || ''] + criteria: [criteria.criteria || ''], + subcriteria: this.fb.array( + criteria.subcriteria?.map((sub: any) => this.createLogbookCriteriaGroup(sub)) || [] + ) }); + + return group; + } + + getSubcriteria(criteriaGroup: FormGroup): FormArray { + return criteriaGroup.get('subcriteria') as FormArray; + } + + addSubcriterion(criteriaGroup: FormGroup): void { + const subcriteria = this.getSubcriteria(criteriaGroup); + subcriteria.push(this.createLogbookCriteriaGroup({ + description: '', + isUniversal: true + })); + } + + removeSubcriterion(criteriaGroup: FormGroup, index: number): void { + const subcriteria = this.getSubcriteria(criteriaGroup); + subcriteria.removeAt(index); } get taggingCriteria(): FormArray { @@ -163,39 +185,44 @@ export class BudgetDetailComponent implements OnInit { this.transferCriteria.removeAt(index); } - saveBudget(): void { - if (!this.budgetForm.valid || !this.budget) return; + buildLogbookCriteriaFromForm(formGroup: FormGroup): any { + const criteriaType = formGroup.get('criteriaType')?.value; + const description = formGroup.get('description')?.value; + const substitution = formGroup.get('substitution')?.value; + const subcriteriaArray = formGroup.get('subcriteria') as FormArray; - this.isLoading = true; - const formValue = this.budgetForm.value; - - const logbookCriteriaValue = formValue.logbookCriteria; - const criteriaType = logbookCriteriaValue.criteriaType; - - const logbookCriteria: any = { - description: logbookCriteriaValue.description, - substitution: logbookCriteriaValue.substitution || undefined, - subcriteria: undefined + const baseCriteria: any = { + description, + substitution: substitution || undefined }; if (criteriaType === 'universal') { - logbookCriteria.isUniversal = true; - logbookCriteria.type = undefined; - logbookCriteria.tags = undefined; - logbookCriteria.criteria = undefined; + baseCriteria.isUniversal = true; } else if (criteriaType === 'tag-based') { - logbookCriteria.type = logbookCriteriaValue.type; - logbookCriteria.tags = logbookCriteriaValue.tags ? - logbookCriteriaValue.tags.split(',').map((t: string) => t.trim()).filter((t: string) => t) : - undefined; - logbookCriteria.isUniversal = undefined; - logbookCriteria.criteria = undefined; + const tags = formGroup.get('tags')?.value; + baseCriteria.type = formGroup.get('type')?.value; + baseCriteria.tags = tags ? tags.split(',').map((t: string) => t.trim()).filter((t: string) => t) : undefined; } else if (criteriaType === 'criteria-based') { - logbookCriteria.criteria = logbookCriteriaValue.criteria; - logbookCriteria.isUniversal = undefined; - logbookCriteria.type = undefined; - logbookCriteria.tags = undefined; + baseCriteria.criteria = formGroup.get('criteria')?.value; + } + + // Recursively build subcriteria + if (subcriteriaArray && subcriteriaArray.length > 0) { + baseCriteria.subcriteria = subcriteriaArray.controls.map(ctrl => + this.buildLogbookCriteriaFromForm(ctrl as FormGroup) + ); } + + return baseCriteria; + } + + saveBudget(): void { + if (!this.budgetForm.valid || !this.budget) return; + + this.isLoading = true; + const formValue = this.budgetForm.value; + + const logbookCriteria = this.buildLogbookCriteriaFromForm(this.logbookCriteria); const request: UpdateBudgetRequest = { name: formValue.name, From cf903999282267a671885754062506a8e604da42 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 7 Oct 2025 20:55:22 +0300 Subject: [PATCH 066/156] fix: add GET endpoint for individual budget to enable YAML download - Add GetBudgetById endpoint to BudgetController - Endpoint supports content negotiation (JSON/YAML) via Accept header - Enables the 'Download YAML' button to work correctly in the UI - Returns 404 with error details if budget not found or access denied --- .../Controllers/BudgetController.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index 4929c2e6..1d92cb05 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -32,6 +32,28 @@ public async Task> GetAvailableBudgets(Cance return budgets.Select(mapper.ToResponse).ToList(); } + /// + /// Gets a specific budget by ID + /// + /// Budget ID + /// Cancellation token + /// Budget details or 404 if not found + [HttpGet("{id}")] + [ProducesResponseType(typeof(BudgetResponse), 200)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async Task GetBudgetById(Guid id, CancellationToken ct) + { + var budgets = await mediator.Send(new ListOwnedBudgetsQuery(), ct); + var budget = budgets.FirstOrDefault(b => b.Id == id); + + if (budget == null) + { + return NotFound(new List { new($"Budget with ID {id} not found or access denied") }); + } + + return Ok(mapper.ToResponse(budget)); + } + /// /// Registers a new budget /// From e531613e849ed9e5f913b3f91c2c2feb7f37ec80 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 7 Oct 2025 21:25:51 +0300 Subject: [PATCH 067/156] feat: add YAML upload functionality for budget updates Backend changes: - Create YamlInputFormatter to deserialize YAML request bodies - Register YamlInputFormatter in MVC input formatters - Add UploadBudgetYaml endpoint (PUT /budget/{id}/upload) with [Consumes('application/yaml')] - Endpoint reuses existing UpdateBudget logic with YAML deserialization Frontend changes: - Add uploadBudgetYaml method to BudgetApiService - Add uploadYaml method to BudgetDetailComponent with file picker - Add 'Upload YAML' button to budget detail page - Users can now download, edit offline, and re-upload budget YAML files - File picker only accepts .yaml and .yml files - Success/error notifications for upload operations --- .../Controllers/BudgetController.cs | 21 ++++++++++ .../Formatters/YamlInputFormatter.cs | 41 +++++++++++++++++++ .../WebControllersExtensions.cs | 5 +++ .../src/app/budget/budget-api.service.ts | 10 +++-- .../budget-detail.component.html | 8 ++++ .../budget-detail/budget-detail.component.ts | 26 ++++++++++++ 6 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlInputFormatter.cs diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index 1d92cb05..06611fc2 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -225,6 +225,27 @@ public async Task UpdateBudget( return BadRequest(result.Errors); } + /// + /// Uploads and updates a budget from YAML file + /// + /// Budget ID + /// Budget update request from YAML + /// Cancellation token + /// Success or error details + [HttpPut("{id:guid}/upload")] + [Consumes("application/yaml", "text/yaml")] + [ProducesResponseType(204)] + [ProducesResponseType(typeof(IEnumerable), 400)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async Task UploadBudgetYaml( + [FromRoute] Guid id, + [FromBody] UpdateBudgetRequest request, + CancellationToken ct) + { + // Reuse the existing UpdateBudget logic + return await UpdateBudget(id, request, ct); + } + /// /// Removes a budget /// diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlInputFormatter.cs b/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlInputFormatter.cs new file mode 100644 index 00000000..e57fcade --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlInputFormatter.cs @@ -0,0 +1,41 @@ +using System.Text; +using Microsoft.AspNetCore.Mvc.Formatters; +using YamlDotNet.Serialization; + +namespace NVs.Budget.Controllers.Web.Formatters; + +internal class YamlInputFormatter : TextInputFormatter +{ + private readonly IDeserializer _deserializer; + + public YamlInputFormatter(IDeserializer deserializer) + { + _deserializer = deserializer; + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + SupportedMediaTypes.Add("application/yaml"); + SupportedMediaTypes.Add("text/yaml"); + } + + public override async Task ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding) + { + var httpContext = context.HttpContext; + + using (var reader = new StreamReader(httpContext.Request.Body, encoding)) + { + try + { + var yaml = await reader.ReadToEndAsync(); + var model = _deserializer.Deserialize(yaml, context.ModelType); + return await InputFormatterResult.SuccessAsync(model); + } + catch (Exception ex) + { + context.ModelState.AddModelError(string.Empty, $"Failed to parse YAML: {ex.Message}"); + return await InputFormatterResult.FailureAsync(); + } + } + } +} + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs index b278bebd..eb22a53d 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs @@ -72,7 +72,12 @@ public static IServiceCollection AddWebControllers(this IServiceCollection servi .WithNamingConvention(CamelCaseNamingConvention.Instance) .Build(); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + opts.OutputFormatters.Add(new YamlOutputFormatter(serializer)); + opts.InputFormatters.Add(new YamlInputFormatter(deserializer)); opts.FormatterMappings.SetMediaTypeMappingForFormat("yaml", "application/yaml"); }) .ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts index f7df0fd3..02b0a05a 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts @@ -107,10 +107,12 @@ export class BudgetApiService { /** * Upload budget configuration from YAML file */ - uploadBudgetYaml(file: File): Observable { - const formData = new FormData(); - formData.append('file', file); - return this.http.put(`${this.baseUrl}/budget/:upload`, formData, { withCredentials: true }); + uploadBudgetYaml(id: string, file: File): Observable { + const headers = new HttpHeaders().set('Content-Type', 'application/yaml'); + return this.http.put(`${this.baseUrl}/budget/${id}/upload`, file, { + headers, + withCredentials: true + }).pipe(tap(() => this.refresh$.next(true))); } /** diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html index 34269637..9efc895e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html @@ -22,6 +22,14 @@

{{ budget.name }}

(click)="downloadYaml()"> Download YAML + + + @if (!isEditMode) { + + } + + + + + @if (isAddingNew && settingsForm) { +
+

Add New Pattern

+
+
+ + + + +
+ +
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+
+ + +
+
+

Fields

+ +
+
+ @for (field of fields.controls; track $index; let i = $index) { +
+ + + + + + + + + +
+ } +
+
+ + +
+
+

Attributes

+ +
+
+ @for (attr of attributes.controls; track $index; let i = $index) { +
+ + + + + + + + + +
+ } +
+
+ + +
+
+

Validation Rules

+ +
+
+ @for (rule of validationRules.controls; track $index; let i = $index) { +
+
+ + + + + + + + + + + + +
+
+ + + + + +
+
+ } +
+
+ +
+ + +
+
+
+ } + + + @if (!isAddingNew) { +
+ @if (getPatterns().length === 0) { +

No file reading patterns configured. Click "Add New Pattern" to create one.

+ } + + @for (pattern of getPatterns(); track pattern) { +
+ + +
+ {{ pattern }} +
+ + + @if (editingPattern !== pattern) { +
+
+
+ Culture: + {{ settings[pattern].culture }} +
+
+ Encoding: + {{ settings[pattern].encoding }} +
+
+ DateTime Kind: + {{ settings[pattern].dateTimeKind }} +
+ + @if (Object.keys(settings[pattern].fields).length > 0) { +
+
Fields
+
    + @for (field of Object.entries(settings[pattern].fields); track field[0]) { +
  • {{ field[0] }}: {{ field[1] }}
  • + } +
+
+ } + + @if (Object.keys(settings[pattern].attributes).length > 0) { +
+
Attributes
+
    + @for (attr of Object.entries(settings[pattern].attributes); track attr[0]) { +
  • {{ attr[0] }}: {{ attr[1] }}
  • + } +
+
+ } + + @if (settings[pattern].validation.length > 0) { +
+
Validation Rules
+
    + @for (rule of settings[pattern].validation; track $index) { +
  • + {{ rule.pattern }} {{ rule.condition }} {{ rule.value }} +
    Error: {{ rule.errorMessage }} +
  • + } +
+
+ } +
+ +
+ + +
+
+ } + + + @if (editingPattern === pattern && settingsForm) { +
+
+
+ + + + +
+ +
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+
+ + +
+
+

Fields

+ +
+
+ @for (field of fields.controls; track $index; let i = $index) { +
+ + + + + + + + + +
+ } +
+
+ + +
+
+

Attributes

+ +
+
+ @for (attr of attributes.controls; track $index; let i = $index) { +
+ + + + + + + + + +
+ } +
+
+ + +
+
+

Validation Rules

+ +
+
+ @for (rule of validationRules.controls; track $index; let i = $index) { +
+
+ + + + + + + + + + + + +
+
+ + + + + +
+
+ } +
+
+ +
+ + +
+
+
+ } +
+
+
+ } +
+ } + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less new file mode 100644 index 00000000..60bce3b5 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less @@ -0,0 +1,288 @@ +.reading-settings-container { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.settings-card { + padding: 2rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; + + h2 { + margin: 0; + } +} + +.header-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--tui-text-02); + font-style: italic; +} + +.patterns-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.pattern-item { + border: 1px solid var(--tui-base-03); + border-radius: 8px; + overflow: hidden; +} + +.pattern-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: var(--tui-base-02); +} + +.pattern-code { + font-family: monospace; + font-size: 0.95rem; + background: var(--tui-base-01); + padding: 0.25rem 0.5rem; + border-radius: 4px; + word-break: break-all; +} + +.pattern-content { + padding: 1.5rem; +} + +.pattern-details { + margin-bottom: 1rem; +} + +.detail-row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.detail-label { + font-weight: 600; + min-width: 120px; +} + +.detail-value { + color: var(--tui-text-02); +} + +.detail-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--tui-base-03); + + h5 { + margin-bottom: 0.5rem; + } +} + +.detail-list { + list-style: none; + padding: 0; + margin: 0; + + li { + padding: 0.25rem 0; + + strong { + color: var(--tui-text-01); + } + + code { + background: var(--tui-base-02); + padding: 0.125rem 0.25rem; + border-radius: 3px; + font-size: 0.9rem; + } + + em { + color: var(--tui-text-03); + font-size: 0.9rem; + } + } +} + +.pattern-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--tui-base-03); +} + +.edit-section { + background: var(--tui-base-01); + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; +} + +.settings-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; +} + +.form-row { + display: flex; + gap: 1rem; + flex-wrap: wrap; + + .form-group { + min-width: 200px; + } +} + +.subsection { + border: 1px solid var(--tui-base-03); + border-radius: 6px; + padding: 1rem; + background: var(--tui-base-02); +} + +.subsection-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + + h4 { + margin: 0; + } +} + +.key-value-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.key-value-item { + display: flex; + gap: 0.75rem; + align-items: flex-end; + flex-wrap: wrap; + + .key-input { + flex: 1; + min-width: 150px; + } + + .value-input { + flex: 2; + min-width: 200px; + } +} + +.validation-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.validation-item { + border: 1px solid var(--tui-base-03); + padding: 1rem; + border-radius: 4px; + background: var(--tui-base-01); +} + +.validation-row { + display: flex; + gap: 0.75rem; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 0.75rem; + + &:last-child { + margin-bottom: 0; + } + + .pattern-input { + flex: 2; + min-width: 150px; + } + + .condition-input { + flex: 1; + min-width: 120px; + } + + .value-input { + flex: 1; + min-width: 120px; + } + + .error-message-input { + flex: 3; + min-width: 250px; + } +} + +.form-actions { + display: flex; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--tui-base-03); + flex-wrap: wrap; +} + +// Responsive adjustments +@media (max-width: 768px) { + .reading-settings-container { + padding: 1rem; + } + + .settings-card { + padding: 1rem; + } + + .header { + flex-direction: column; + align-items: flex-start; + } + + .form-row, + .key-value-item, + .validation-row { + flex-direction: column; + align-items: stretch; + + .form-group, + .key-input, + .value-input, + .pattern-input, + .condition-input, + .error-message-input { + min-width: 100%; + } + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.spec.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.spec.ts new file mode 100644 index 00000000..ebad5108 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReadingSettingsComponent } from './reading-settings.component'; +import { ActivatedRoute } from '@angular/router'; +import { BudgetApiService } from '../budget-api.service'; +import { of } from 'rxjs'; + +describe('ReadingSettingsComponent', () => { + let component: ReadingSettingsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + const apiServiceMock = { + getReadingSettings: jasmine.createSpy('getReadingSettings').and.returnValue(of({ settings: {} })), + updateReadingSettings: jasmine.createSpy('updateReadingSettings').and.returnValue(of(void 0)) + }; + + const activatedRouteMock = { + params: of({ budgetId: 'test-id' }) + }; + + await TestBed.configureTestingModule({ + imports: [ReadingSettingsComponent], + providers: [ + { provide: BudgetApiService, useValue: apiServiceMock }, + { provide: ActivatedRoute, useValue: activatedRouteMock } + ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ReadingSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.ts new file mode 100644 index 00000000..0f788d58 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.ts @@ -0,0 +1,312 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, FormArray } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable, map } from 'rxjs'; +import { BudgetApiService } from '../budget-api.service'; +import { FileReadingSettingResponse, ValidationRuleResponse } from '../models'; +import { + TuiButton, + TuiDialogService, + TuiLoader, + TuiTitle, + TuiTextfield, + TuiLabel, + TuiDataList +} from '@taiga-ui/core'; +import { + TuiAccordion +} from '@taiga-ui/kit'; +import { TuiCardLarge } from '@taiga-ui/layout'; + +@Component({ + selector: 'app-reading-settings', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TuiButton, + TuiLoader, + TuiTextfield, + TuiLabel, + TuiCardLarge, + TuiAccordion, + TuiTitle, + TuiDataList + ], + templateUrl: './reading-settings.component.html', + styleUrls: ['./reading-settings.component.less'] +}) +export class ReadingSettingsComponent implements OnInit { + budgetId$!: Observable; + budgetId: string = ''; + settings: Record = {}; + + settingsForm!: FormGroup; + isEditMode = false; + isLoading = false; + editingPattern: string | null = null; + isAddingNew = false; + + readonly dateTimeKindOptions = ['Local', 'Utc', 'Unspecified']; + readonly validationConditionOptions = ['Equals', 'NotEquals']; + + // Make Object available in template + readonly Object = Object; + + constructor( + private route: ActivatedRoute, + private router: Router, + private apiService: BudgetApiService, + private fb: FormBuilder, + private dialogService: TuiDialogService + ) {} + + ngOnInit(): void { + this.budgetId$ = this.route.params.pipe(map(params => params['budgetId'])); + + this.budgetId$.subscribe(id => { + this.budgetId = id; + this.loadSettings(); + }); + } + + loadSettings(): void { + this.isLoading = true; + this.apiService.getReadingSettings(this.budgetId).subscribe({ + next: (response) => { + this.settings = response || {}; + this.isLoading = false; + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to load reading settings'); + } + }); + } + + getPatterns(): string[] { + return Object.keys(this.settings); + } + + startEdit(pattern: string): void { + this.editingPattern = pattern; + const setting = this.settings[pattern]; + this.settingsForm = this.createSettingForm(pattern, setting); + this.isEditMode = true; + } + + startAddNew(): void { + this.isAddingNew = true; + this.editingPattern = null; + this.settingsForm = this.createSettingForm('', { + culture: 'en-US', + encoding: 'utf-8', + dateTimeKind: 'Local', + fields: {}, + attributes: {}, + validation: [] + }); + this.isEditMode = true; + } + + createSettingForm(pattern: string, setting: FileReadingSettingResponse): FormGroup { + return this.fb.group({ + pattern: [pattern, Validators.required], + culture: [setting.culture, Validators.required], + encoding: [setting.encoding, Validators.required], + dateTimeKind: [setting.dateTimeKind, Validators.required], + fields: this.fb.array( + Object.entries(setting.fields).map(([key, value]) => + this.fb.group({ + key: [key, Validators.required], + value: [value, Validators.required] + }) + ) + ), + attributes: this.fb.array( + Object.entries(setting.attributes).map(([key, value]) => + this.fb.group({ + key: [key, Validators.required], + value: [value, Validators.required] + }) + ) + ), + validation: this.fb.array( + setting.validation.map(v => + this.fb.group({ + pattern: [v.pattern, Validators.required], + condition: [v.condition, Validators.required], + value: [v.value, Validators.required], + errorMessage: [v.errorMessage, Validators.required] + }) + ) + ) + }); + } + + get fields(): FormArray { + return this.settingsForm?.get('fields') as FormArray; + } + + get attributes(): FormArray { + return this.settingsForm?.get('attributes') as FormArray; + } + + get validationRules(): FormArray { + return this.settingsForm?.get('validation') as FormArray; + } + + addField(): void { + this.fields.push(this.fb.group({ + key: ['', Validators.required], + value: ['', Validators.required] + })); + } + + removeField(index: number): void { + this.fields.removeAt(index); + } + + addAttribute(): void { + this.attributes.push(this.fb.group({ + key: ['', Validators.required], + value: ['', Validators.required] + })); + } + + removeAttribute(index: number): void { + this.attributes.removeAt(index); + } + + addValidationRule(): void { + this.validationRules.push(this.fb.group({ + pattern: ['', Validators.required], + condition: ['Equals', Validators.required], + value: ['', Validators.required], + errorMessage: ['', Validators.required] + })); + } + + removeValidationRule(index: number): void { + this.validationRules.removeAt(index); + } + + saveSettings(): void { + if (!this.settingsForm.valid) return; + + this.isLoading = true; + const formValue = this.settingsForm.value; + + // Convert arrays to dictionaries + const fields: Record = {}; + formValue.fields.forEach((f: any) => { + fields[f.key] = f.value; + }); + + const attributes: Record = {}; + formValue.attributes.forEach((a: any) => { + attributes[a.key] = a.value; + }); + + const validation: ValidationRuleResponse[] = formValue.validation.map((v: any) => ({ + pattern: v.pattern, + condition: v.condition, + value: v.value, + errorMessage: v.errorMessage + })); + + const newSetting: FileReadingSettingResponse = { + culture: formValue.culture, + encoding: formValue.encoding, + dateTimeKind: formValue.dateTimeKind, + fields, + attributes, + validation + }; + + // Create updated settings + const updatedSettings = { ...this.settings }; + + // If editing and pattern changed, remove old pattern + if (this.editingPattern && this.editingPattern !== formValue.pattern) { + delete updatedSettings[this.editingPattern]; + } + + updatedSettings[formValue.pattern] = newSetting; + + this.apiService.updateReadingSettings(this.budgetId, updatedSettings).subscribe({ + next: () => { + this.isLoading = false; + this.isEditMode = false; + this.isAddingNew = false; + this.editingPattern = null; + this.showSuccess('Settings saved successfully'); + this.loadSettings(); + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to save settings'); + } + }); + } + + deletePattern(pattern: string): void { + if (!confirm(`Are you sure you want to delete the pattern "${pattern}"?`)) { + return; + } + + this.isLoading = true; + const updatedSettings = { ...this.settings }; + delete updatedSettings[pattern]; + + this.apiService.updateReadingSettings(this.budgetId, updatedSettings).subscribe({ + next: () => { + this.isLoading = false; + this.showSuccess('Pattern deleted successfully'); + this.loadSettings(); + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to delete pattern'); + } + }); + } + + cancelEdit(): void { + this.isEditMode = false; + this.isAddingNew = false; + this.editingPattern = null; + this.settingsForm = null as any; + } + + goBack(): void { + this.router.navigate(['/budget', this.budgetId]); + } + + private showSuccess(message: string): void { + this.dialogService + .open(message, { label: 'Success', size: 's' }) + .subscribe(); + } + + private handleError(error: any, defaultMessage: string): void { + let errorMessage = defaultMessage; + + if (error?.error) { + if (Array.isArray(error.error)) { + const errors = error.error.map((e: any) => e.message || e).join(', '); + errorMessage = `${defaultMessage}: ${errors}`; + } else if (typeof error.error === 'string') { + errorMessage = `${defaultMessage}: ${error.error}`; + } else if (error.error.message) { + errorMessage = `${defaultMessage}: ${error.error.message}`; + } + } + + this.dialogService + .open(errorMessage, { label: 'Error', size: 'm' }) + .subscribe(); + } +} + From 44d60f7911593dd85b3d2cd0cd6f7fbd43632ef5 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 11 Oct 2025 14:57:53 +0300 Subject: [PATCH 075/156] fix: fixed budgetselector --- .../src/app/budget/budget-selector/budget-selector.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts index c6dbfd5b..f6d38023 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-selector/budget-selector.component.ts @@ -14,7 +14,7 @@ import { AsyncPipe, CommonModule } from '@angular/common'; imports: [CommonModule, TuiButton, TuiChevron, TuiDataList, TuiDropdown, RouterLink, AsyncPipe] }) export class BudgetSelectorComponent implements OnInit, OnDestroy { - private budgetIdPattern = new RegExp("^/budget/([^/]+)$"); + private budgetIdPattern = new RegExp("^/budget/([^/]+)"); private budgetSub: Subscription | undefined; private routerSub: Subscription | undefined; private selectedBudgetId: string | null = null; From 0a7ca700447c2542e00327975a102013a41b5d45 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 11 Oct 2025 15:01:59 +0300 Subject: [PATCH 076/156] feat: yaml settings --- .../src/app/budget/budget-api.service.ts | 22 ++++++++ .../reading-settings.component.html | 16 ++++++ .../reading-settings.component.ts | 55 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts index 680ae9e8..3cebb78c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-api.service.ts @@ -154,4 +154,26 @@ export class BudgetApiService { withCredentials: true }); } + + /** + * Download reading settings as YAML + */ + downloadReadingSettingsYaml(budgetId: string): Observable { + return this.http.get(`${this.baseUrl}/budget/${budgetId}/reading-settings`, { + responseType: 'blob', + headers: new HttpHeaders().set('Accept', 'application/yaml'), + withCredentials: true + }); + } + + /** + * Upload reading settings from YAML content + */ + uploadReadingSettingsYaml(budgetId: string, yamlContent: string): Observable { + const headers = new HttpHeaders().set('Content-Type', 'application/yaml'); + return this.http.put(`${this.baseUrl}/budget/${budgetId}/reading-settings`, yamlContent, { + headers, + withCredentials: true + }); + } } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.html index da4f378f..5bbd4daa 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.html @@ -22,6 +22,22 @@

File Reading Settings

(click)="startAddNew()"> Add New Pattern + + } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.ts index 0f788d58..3e2ec00b 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.ts @@ -284,6 +284,61 @@ export class ReadingSettingsComponent implements OnInit { this.router.navigate(['/budget', this.budgetId]); } + downloadYaml(): void { + this.isLoading = true; + this.apiService.downloadReadingSettingsYaml(this.budgetId).subscribe({ + next: (blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `reading-settings-${this.budgetId}.yaml`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + this.isLoading = false; + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to download YAML'); + } + }); + } + + uploadYaml(): void { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.yaml,.yml'; + input.onchange = (event: any) => { + const file = event.target?.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const yamlContent = e.target?.result as string; + if (!yamlContent) { + this.handleError({}, 'Failed to read file content'); + return; + } + + this.isLoading = true; + this.apiService.uploadReadingSettingsYaml(this.budgetId, yamlContent).subscribe({ + next: () => { + this.isLoading = false; + this.showSuccess('Reading settings updated successfully from YAML'); + this.loadSettings(); + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to upload YAML'); + } + }); + }; + reader.readAsText(file); + }; + input.click(); + } + private showSuccess(message: string): void { this.dialogService .open(message, { label: 'Success', size: 's' }) From ff57041b2bcbf49902994262a82e3a192b80c353 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 12 Oct 2025 18:49:20 +0300 Subject: [PATCH 077/156] chore: adjusted script to start in split console (win only) --- src/Hosts/web-debug/start-all.ps1 | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Hosts/web-debug/start-all.ps1 b/src/Hosts/web-debug/start-all.ps1 index f786b037..7f5d95dc 100644 --- a/src/Hosts/web-debug/start-all.ps1 +++ b/src/Hosts/web-debug/start-all.ps1 @@ -100,16 +100,12 @@ To stop all services, close all windows and run: docker compose down # Start server in a new PowerShell window $serverScript = Join-Path $PSScriptRoot "start-server.ps1" -Start-Process pwsh -ArgumentList "-NoExit", "-File", "`"$serverScript`"" + wt sp -d . pwsh $serverScript # Wait a moment before starting client Start-Sleep -Seconds 2 # Start client in a new PowerShell window $clientScript = Join-Path $PSScriptRoot "start-client.ps1" -Start-Process pwsh -ArgumentList "-NoExit", "-File", "`"$clientScript`"" - -Write-Host "All services started! Check the new windows for logs." -ForegroundColor Green -Write-Host "Press any key to exit this launcher (services will continue running)..." -ForegroundColor Gray -$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") +pwsh $clientScript From 4650b4947f650ded3d65f952fa189bfa9c92b908 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 12 Oct 2025 19:21:58 +0300 Subject: [PATCH 078/156] fix: fixed dropdowns. They still looks creepy, but works at least --- .../reading-settings.component.html | 48 +++++++++---------- .../reading-settings.component.less | 39 +++++++++++++++ 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.html index 5bbd4daa..9371aeb1 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.html @@ -69,15 +69,13 @@

Add New Pattern

-
- - - - +
+ +
@@ -141,14 +139,14 @@

Validation Rules

- - - @for (option of validationConditionOptions; track option) { } - + @@ -291,15 +289,13 @@
Validation Rules
-
- - - - +
+ +
@@ -363,14 +359,14 @@

Validation Rules

- - - @for (option of validationConditionOptions; track option) { } - + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less index 60bce3b5..8346ec69 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less @@ -254,6 +254,45 @@ flex-wrap: wrap; } +.select-field { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; +} + +.select-label { + font-size: 0.875rem; + color: var(--tui-text-02); + font-weight: 500; +} + +.tui-select { + width: 100%; + padding: 0.75rem; + font-size: 0.9375rem; + border: 1px solid var(--tui-border-normal); + border-radius: 8px; + background-color: var(--tui-background-base); + color: var(--tui-text-primary); + cursor: pointer; + transition: border-color 0.2s ease; + + &:hover { + border-color: var(--tui-border-hover); + } + + &:focus { + outline: none; + border-color: var(--tui-border-focus); + box-shadow: 0 0 0 3px var(--tui-background-accent-1); + } + + option { + padding: 0.5rem; + } +} + // Responsive adjustments @media (max-width: 768px) { .reading-settings-container { From c0f40b6bb54230b50ce53b76ce0ac77e42976140 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 14 Oct 2025 22:30:36 +0300 Subject: [PATCH 079/156] feat: Add light/dark theme toggle for Taiga UI - Add ThemeService to manage theme state and persistence - Add theme toggle button with sun/moon icons in app header - Bind tuiTheme attribute to tui-root for proper theme propagation - Store theme preference in localStorage with system preference fallback - Update header styling for better alignment of toggle button --- .../budget-client/src/app/app.component.html | 13 +++++-- .../budget-client/src/app/app.component.less | 2 ++ .../budget-client/src/app/app.component.ts | 14 +++++--- .../budget-client/src/app/theme.service.ts | 34 +++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/theme.service.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html index 598e4a47..a2c9b71e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.html @@ -1,10 +1,19 @@ - +
The Budget. - + + {{ownerName$ | async}} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less index 23a2e126..05d3a7b0 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.less @@ -5,4 +5,6 @@ .flex { display:flex; + align-items: center; + gap: 1rem; } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts index c6f08755..404be52d 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { TuiRoot } from "@taiga-ui/core"; +import { TuiRoot, TuiButton, TuiIcon } from "@taiga-ui/core"; import { TuiBlockStatus, TuiNavigation } from "@taiga-ui/layout" import { Component, enableProdMode } from '@angular/core'; import { RouterLink, RouterOutlet } from '@angular/router'; @@ -8,14 +8,15 @@ import { UserService } from "./auth/user.service"; import { CommonModule } from "@angular/common"; import { map } from "rxjs"; import { BudgetSelectorComponent } from "./budget/budget-selector/budget-selector.component"; +import { ThemeService } from "./theme.service"; if (environment.production) { enableProdMode(); - } +} @Component({ selector: 'app-root', - imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, CommonModule, RouterLink, BudgetSelectorComponent], + imports: [RouterOutlet, AuthComponent, TuiRoot, TuiNavigation, TuiBlockStatus, CommonModule, RouterLink, BudgetSelectorComponent, TuiButton, TuiIcon], templateUrl: './app.component.html', styleUrl: './app.component.less' }) @@ -26,6 +27,11 @@ export class AppComponent { get isAuthenticated$() { return this.user.current$.pipe(map(u => u.isAuthenticated)); } get userId$() { return this.user.current$.pipe(map(u => u.id)); } get ownerName$() { return this.user.current$.pipe(map(u => u.ownerInfo?.name)); } + get isDarkTheme$() { return this.theme.isDark$; } - constructor(private user: UserService) { } + constructor(private user: UserService, private theme: ThemeService) { } + + toggleTheme(): void { + this.theme.toggleTheme(); + } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/theme.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/theme.service.ts new file mode 100644 index 00000000..67898227 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/theme.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ThemeService { + private readonly THEME_KEY = 'budget-app-theme'; + private isDarkTheme$ = new BehaviorSubject(this.getInitialTheme()); + + get isDark$(): Observable { + return this.isDarkTheme$.asObservable(); + } + + get isDark(): boolean { + return this.isDarkTheme$.value; + } + + toggleTheme(): void { + const newTheme = !this.isDarkTheme$.value; + this.isDarkTheme$.next(newTheme); + localStorage.setItem(this.THEME_KEY, newTheme ? 'dark' : 'light'); + } + + private getInitialTheme(): boolean { + const savedTheme = localStorage.getItem(this.THEME_KEY); + if (savedTheme) { + return savedTheme === 'dark'; + } + // Check system preference + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + } +} + From c5024f03c10127780aeb526d53221ae0edb172da Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 14 Oct 2025 22:31:14 +0300 Subject: [PATCH 080/156] fix(csv-import):codepages support --- .../Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs index b8371461..f9f22762 100644 --- a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using NVs.Budget.Infrastructure.Files.CSV.Contracts; @@ -13,6 +14,8 @@ public static class CsvExtensions { public static IServiceCollection AddCsvFiles(this IServiceCollection services, string connectionString) { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + services.AddDbContext(o => o.UseNpgsql(connectionString)) .AddTransient>() .AddScoped(); From 09b5b74a86cefe568e4e7ea5cfd13e7a82f1432b Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Tue, 14 Oct 2025 22:31:34 +0300 Subject: [PATCH 081/156] feat: group of logbook criteria --- .../budget-detail.component.html | 85 ++++++++++++++++--- .../budget-detail/budget-detail.component.ts | 30 +++++-- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html index c34d51f8..ff8dea8c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html @@ -200,8 +200,8 @@

Logbook Criteria

+ + @if (logbookCriteria.get('criteriaType')?.value === 'group') { +
+ +
+ } + @if (logbookCriteria.get('criteriaType')?.value === 'tag-based') {
@@ -240,8 +250,16 @@

Logbook Criteria

} + + @if (logbookCriteria.get('criteriaType')?.value === 'group' && !logbookCriteria.get('isUniversal')?.value) { + + + + + } + - @if (logbookCriteria.get('criteriaType')?.value !== 'universal') { + @if (logbookCriteria.get('criteriaType')?.value !== 'group' || !logbookCriteria.get('isUniversal')?.value) { @@ -286,8 +304,8 @@

Subcriteria

+ + @if (subcriterion.get('criteriaType')?.value === 'group') { +
+ +
+ } + @if (subcriterion.get('criteriaType')?.value === 'tag-based') {
@@ -326,8 +354,16 @@

Subcriteria

} + + @if (subcriterion.get('criteriaType')?.value === 'group' && !subcriterion.get('isUniversal')?.value) { + + + + + } + - @if (subcriterion.get('criteriaType')?.value !== 'universal') { + @if (subcriterion.get('criteriaType')?.value !== 'group' || !subcriterion.get('isUniversal')?.value) { @@ -372,8 +408,8 @@
Nested Subcriteria
+ + @if (nestedSub.get('criteriaType')?.value === 'group') { +
+ +
+ } + @if (nestedSub.get('criteriaType')?.value === 'tag-based') {
@@ -409,7 +455,14 @@
Nested Subcriteria
} - @if (nestedSub.get('criteriaType')?.value !== 'universal') { + @if (nestedSub.get('criteriaType')?.value === 'group' && !nestedSub.get('isUniversal')?.value) { + + + + + } + + @if (nestedSub.get('criteriaType')?.value !== 'group' || !nestedSub.get('isUniversal')?.value) { @@ -521,8 +574,16 @@

Logbook Criteria

Description: {{ criteria.description || 'N/A' }}
- @if (criteria.isUniversal) { - Universal + @if (criteria.subcriteria && criteria.subcriteria.length > 0) { +
+ Type: + Group +
+ @if (criteria.isUniversal) { +
+ Universal (matches all operations) +
+ } } @if (criteria.type) {
@@ -542,7 +603,7 @@

Logbook Criteria

} @if (criteria.criteria) {
- Criteria: + {{ (criteria.subcriteria && criteria.subcriteria.length > 0) ? 'Pre-filter:' : 'Criteria:' }} {{ criteria.criteria }}
} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts index 5baec1a6..1e718803 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts @@ -102,18 +102,24 @@ export class BudgetDetailComponent implements OnInit { createLogbookCriteriaGroup(criteria: any): FormGroup { // Determine criteria type - let criteriaType = 'universal'; + let criteriaType = 'group'; + let isUniversal = false; + if (criteria.type && criteria.tags) { criteriaType = 'tag-based'; - } else if (criteria.criteria) { + } else if (criteria.criteria && (!criteria.subcriteria || criteria.subcriteria.length === 0)) { + // It's criteria-based only if it has criteria expression but no subcriteria criteriaType = 'criteria-based'; - } else if (criteria.isUniversal) { - criteriaType = 'universal'; + } else { + // It's a group (has subcriteria or is universal) + criteriaType = 'group'; + isUniversal = criteria.isUniversal || false; } const group = this.fb.group({ criteriaType: [criteriaType], description: [criteria.description || '', Validators.required], + isUniversal: [isUniversal], type: [criteria.type || ''], tags: [criteria.tags ? criteria.tags.join(', ') : ''], substitution: [criteria.substitution || ''], @@ -134,7 +140,7 @@ export class BudgetDetailComponent implements OnInit { const subcriteria = this.getSubcriteria(criteriaGroup); subcriteria.push(this.createLogbookCriteriaGroup({ description: '', - isUniversal: true + subcriteria: [] })); } @@ -190,14 +196,24 @@ export class BudgetDetailComponent implements OnInit { const description = formGroup.get('description')?.value; const substitution = formGroup.get('substitution')?.value; const subcriteriaArray = formGroup.get('subcriteria') as FormArray; + const isUniversal = formGroup.get('isUniversal')?.value; const baseCriteria: any = { description, substitution: substitution || undefined }; - if (criteriaType === 'universal') { - baseCriteria.isUniversal = true; + if (criteriaType === 'group') { + // Group type - may have isUniversal flag and/or pre-filter criteria + if (isUniversal) { + baseCriteria.isUniversal = true; + } + + // Add pre-filter criteria if specified and not universal + const criteriaExpr = formGroup.get('criteria')?.value; + if (criteriaExpr && !isUniversal) { + baseCriteria.criteria = criteriaExpr; + } } else if (criteriaType === 'tag-based') { const tags = formGroup.get('tags')?.value; baseCriteria.type = formGroup.get('type')?.value; From cdb9dd396c0f1ba6630379a30e77ac77aa695875 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Wed, 15 Oct 2025 08:50:51 +0300 Subject: [PATCH 082/156] feat: extend logbook criteria nesting to 5 levels and add section toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extended logbook subcriteria support from 3 to 5 nested levels - Added Level 4 and Level 5 subcriteria with full form controls - Each level has progressively compact styling for visual hierarchy - Added show/hide toggle buttons for entire sections (Tagging, Transfer, Logbook) - All sections start expanded by default - Toggle buttons display visual indicators (▼ Hide / ▶ Show) - Improved form usability for managing large budget configurations --- .../budget-detail.component.html | 418 ++++++++++++++---- .../budget-detail.component.less | 77 ++++ .../budget-detail/budget-detail.component.ts | 17 + 3 files changed, 426 insertions(+), 86 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html index ff8dea8c..39cb9fb8 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html @@ -90,107 +90,148 @@

Basic Information

-

Tagging Criteria

- +
+

Tagging Criteria

+ +
+ @if (showTaggingCriteria) { + + }
- -
- @for (criterion of taggingCriteria.controls; track $index; let i = $index) { - -
- Tagging Criterion {{ i + 1 }} - -
- -
- - - - + @if (showTaggingCriteria) { + +
+ @for (criterion of taggingCriteria.controls; track $index; let i = $index) { + +
+ Tagging Criterion {{ i + 1 }} + +
- - - - -
- - } -
- +
+ + + + + + + + + +
+
+ } +
+
+ }
-

Transfer Criteria

- +
+

Transfer Criteria

+ +
+ @if (showTransferCriteria) { + + }
- -
- @for (criterion of transferCriteria.controls; track $index; let i = $index) { - -
- Transfer Criterion {{ i + 1 }} - -
- -
- - - - + @if (showTransferCriteria) { + +
+ @for (criterion of transferCriteria.controls; track $index; let i = $index) { + +
+ Transfer Criterion {{ i + 1 }} + +
- - - - - - - - - -
- - } -
- +
+ + + + + + + + + + + + + + +
+
+ } +
+
+ }
-

Logbook Criteria

+
+
+

Logbook Criteria

+ +
+
-
+ @if (showLogbookCriteria) { +
@@ -469,7 +510,211 @@
Nested Subcriteria
} -

Note: Further nesting beyond this level is not supported in the UI.

+ +
+
Level 4 Subcriteria
+ + + @for (level4Sub of getSubcriteria($any(nestedSub)).controls; track $index; let l4Idx = $index) { +
+ +
+ Level 4 - {{ l4Idx + 1 }} + +
+ +
+ + + + + +
+ +
+ + + +
+
+ + @if (level4Sub.get('criteriaType')?.value === 'group') { +
+ +
+ } + + @if (level4Sub.get('criteriaType')?.value === 'tag-based') { +
+ + +
+ + + + + } + + @if (level4Sub.get('criteriaType')?.value === 'criteria-based') { + + + + + } + + @if (level4Sub.get('criteriaType')?.value === 'group' && !level4Sub.get('isUniversal')?.value) { + + + + + } + + @if (level4Sub.get('criteriaType')?.value !== 'group' || !level4Sub.get('isUniversal')?.value) { + + + + + } + + +
+
Level 5 Subcriteria
+ + + @for (level5Sub of getSubcriteria($any(level4Sub)).controls; track $index; let l5Idx = $index) { +
+ +
+ Level 5 - {{ l5Idx + 1 }} + +
+ +
+ + + + + +
+ +
+ + + +
+
+ + @if (level5Sub.get('criteriaType')?.value === 'group') { +
+ +
+ } + + @if (level5Sub.get('criteriaType')?.value === 'tag-based') { +
+ + +
+ + + + + } + + @if (level5Sub.get('criteriaType')?.value === 'criteria-based') { + + + + + } + + @if (level5Sub.get('criteriaType')?.value === 'group' && !level5Sub.get('isUniversal')?.value) { + + + + + } + + @if (level5Sub.get('criteriaType')?.value !== 'group' || !level5Sub.get('isUniversal')?.value) { + + + + + } + +

Note: This is the deepest nesting level (Level 5). Further nesting is not supported.

+
+
+
+ } +
+
+
+
+ } +
@@ -481,6 +726,7 @@
Nested Subcriteria
}
+ } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less index f1b4c5ca..cda082e6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less @@ -77,6 +77,17 @@ margin-bottom: 0.5rem; } +.section-title-with-toggle { + display: flex; + align-items: center; + gap: 0.75rem; + + button { + min-width: 5rem; + font-size: 0.8125rem; + } +} + .criterion-fields { display: flex; flex-direction: column; @@ -335,6 +346,72 @@ tui-accordion-item { border-left: 2px solid var(--tui-secondary); } +.level4-subcriteria { + margin-top: 1rem; + padding: 0.75rem; + border: 1px dashed var(--tui-border-normal); + border-radius: 6px; + background: var(--tui-background-neutral-1); + + h6 { + font-size: 0.8125rem; + margin-bottom: 0.5rem; + color: var(--tui-text-secondary); + font-weight: 600; + } + + button { + margin-bottom: 0.5rem; + } +} + +.level4-subcriterion-card { + margin-top: 0.5rem; + padding: 0.625rem; + border: 1px solid var(--tui-border-normal); + border-radius: 6px; + background: var(--tui-background-base); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03); +} + +.level4-fields { + padding-left: 0.625rem; + border-left: 2px solid var(--tui-accent); +} + +.level5-subcriteria { + margin-top: 0.75rem; + padding: 0.5rem; + border: 1px dashed var(--tui-border-normal); + border-radius: 4px; + background: var(--tui-background-neutral-2); + + h6 { + font-size: 0.75rem; + margin-bottom: 0.5rem; + color: var(--tui-text-tertiary); + font-weight: 600; + } + + button { + margin-bottom: 0.5rem; + } +} + +.level5-subcriterion-card { + margin-top: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--tui-border-normal); + border-radius: 4px; + background: var(--tui-background-base); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02); +} + +.level5-fields { + padding-left: 0.5rem; + border-left: 2px solid var(--tui-info); +} + .nesting-note { margin-top: 0.5rem; font-size: 0.8125rem; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts index 1e718803..bdafd94a 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.ts @@ -48,6 +48,11 @@ export class BudgetDetailComponent implements OnInit { isEditMode = false; isLoading = false; + // Section visibility toggles + showTaggingCriteria = true; + showTransferCriteria = true; + showLogbookCriteria = true; + // LogbookCriteria type options readonly tagBasedCriterionTypes = ['Including', 'Excluding', 'OneOf']; @@ -191,6 +196,18 @@ export class BudgetDetailComponent implements OnInit { this.transferCriteria.removeAt(index); } + toggleTaggingCriteria(): void { + this.showTaggingCriteria = !this.showTaggingCriteria; + } + + toggleTransferCriteria(): void { + this.showTransferCriteria = !this.showTransferCriteria; + } + + toggleLogbookCriteria(): void { + this.showLogbookCriteria = !this.showLogbookCriteria; + } + buildLogbookCriteriaFromForm(formGroup: FormGroup): any { const criteriaType = formGroup.get('criteriaType')?.value; const description = formGroup.get('description')?.value; From c09a1c42cfdaa9f86ae6815eac09f575b24381c0 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Wed, 15 Oct 2025 23:27:36 +0300 Subject: [PATCH 083/156] feat: Add operations management with REST API and Angular UI Backend (Web API): - Add OperationsController with RESTful endpoints for operations CRUD * GET /api/v0.1/budget/{budgetId}/operations - List operations with streaming support * POST /api/v0.1/budget/{budgetId}/operations/import - Import operations from CSV * PUT /api/v0.1/budget/{budgetId}/operations - Update existing operations * DELETE /api/v0.1/budget/{budgetId}/operations - Remove operations by criteria - Add OperationMapper for request/response transformations - Add operation models (OperationResponse, ImportResultResponse, UpdateResultResponse, etc.) - Implement global exception handling middleware - Add custom HTTP exceptions (BadRequestException, NotFoundException, etc.) - Return full operation collections in import/update results instead of counts - Use streaming IAsyncEnumerable for GET endpoint - Integrate with existing MediatR commands and queries Frontend (Angular UI): - Add OperationsApiService for API communication - Add operation TypeScript models matching backend DTOs - Create ImportOperationsComponent for CSV import with: * File upload and parsing * Transfer confidence level configuration * Import results display (registered, duplicates, errors) - Create OperationsListComponent with: * Expandable operation cards * Filtering by criteria, currency, and transfers * Color-coded amounts (positive/negative) * Display tags and custom attributes - Add routes for operations import and list views - Integrate operations navigation from budget detail page - Use Taiga UI components for consistent design All changes follow established patterns and integrate seamlessly with existing codebase. --- .../Controllers/OperationsController.cs | 312 ++++++++++++++++++ .../Exceptions/HttpException.cs | 77 +++++ .../Middleware/ExceptionHandlingMiddleware.cs | 73 ++++ .../Models/OperationResponse.cs | 66 ++++ .../Utils/OperationMapper.cs | 103 ++++++ .../WebControllersExtensions.cs | 5 + .../budget-client/src/app/app.routes.ts | 4 + .../budget-detail.component.html | 8 + .../budget-detail/budget-detail.component.ts | 6 + .../budget-client/src/app/budget/models.ts | 62 ++++ .../import-operations.component.html | 117 +++++++ .../import-operations.component.less | 138 ++++++++ .../import-operations.component.ts | 224 +++++++++++++ .../app/operations/operations-api.service.ts | 105 ++++++ .../operations-list.component.html | 176 ++++++++++ .../operations-list.component.less | 235 +++++++++++++ .../operations-list.component.ts | 168 ++++++++++ 17 files changed, 1879 insertions(+) create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Exceptions/HttpException.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Utils/OperationMapper.cs create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs new file mode 100644 index 00000000..8bb04696 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -0,0 +1,312 @@ +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using Asp.Versioning; +using FluentResults; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Application.Contracts.Options; +using NVs.Budget.Application.Contracts.Queries; +using NVs.Budget.Application.Contracts.UseCases.Budgets; +using NVs.Budget.Application.Contracts.UseCases.Operations; +using NVs.Budget.Controllers.Web.Exceptions; +using NVs.Budget.Controllers.Web.Models; +using NVs.Budget.Controllers.Web.Utils; +using NVs.Budget.Utilities.Expressions; + +namespace NVs.Budget.Controllers.Web.Controllers; + +[Authorize] +[ApiVersion("0.1")] +[Route("api/v{version:apiVersion}/budget/{budgetId:guid}/[controller]")] +[Produces("application/json", "application/yaml", "text/yaml")] +public class OperationsController( + IMediator mediator, + OperationMapper mapper, + ReadableExpressionsParser parser) : Controller +{ + /// + /// Gets all operations for a specific budget + /// + /// Budget ID to filter operations + /// Optional filter criteria expression + /// Optional output currency for conversion + /// Whether to exclude transfers from results + /// Cancellation token + /// Collection of operations + [HttpGet] + [ProducesResponseType(typeof(IAsyncEnumerable), 200)] + [ProducesResponseType(typeof(IEnumerable), 400)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async IAsyncEnumerable GetOperations( + [FromRoute] Guid budgetId, + [FromQuery] string? criteria = null, + [FromQuery] string? outputCurrency = null, + [FromQuery] bool excludeTransfers = false, + [EnumeratorCancellation] CancellationToken ct = default) + { + // Validate budget access + var budgets = await mediator.Send(new ListOwnedBudgetsQuery(), ct); + var budget = budgets.FirstOrDefault(b => b.Id == budgetId); + + if (budget == null) + { + throw new NotFoundException($"Budget with ID {budgetId} not found or access denied"); + } + + // Parse user-provided criteria if specified + Expression>? conditions = null; + if (!string.IsNullOrWhiteSpace(criteria)) + { + var criteriaResult = parser.ParseUnaryPredicate(criteria); + if (criteriaResult.IsFailed) + { + throw new BadRequestException(criteriaResult.Errors); + } + + conditions = criteriaResult.Value.AsExpression(); + } + + // Parse output currency if provided + NMoneys.Currency? currency = null; + if (!string.IsNullOrWhiteSpace(outputCurrency)) + { + try + { + currency = NMoneys.Currency.Get(outputCurrency); + } + catch (Exception ex) + { + throw new BadRequestException($"Invalid currency code: {outputCurrency}. {ex.Message}"); + } + } + + var query = new OperationQuery(conditions, currency, excludeTransfers); + var listQuery = new ListOperationsQuery(query); + + await foreach (var operation in mediator.CreateStream(listQuery, ct)) + { + yield return mapper.ToResponse(operation); + } + } + + /// + /// Imports new operations into a budget + /// + /// Budget ID from route + /// Import operations request + /// Cancellation token + /// Import result with success/failure details + [HttpPost("import")] + [Consumes("application/json", "application/yaml", "text/yaml")] + [ProducesResponseType(typeof(ImportResultResponse), 200)] + [ProducesResponseType(typeof(IEnumerable), 400)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async Task ImportOperations( + [FromRoute] Guid budgetId, + [FromBody] ImportOperationsRequest request, + CancellationToken ct) + { + // Validate budget access + var budgets = await mediator.Send(new ListOwnedBudgetsQuery(), ct); + var budget = budgets.FirstOrDefault(b => b.Id == budgetId); + + if (budget == null) + { + return NotFound(new List { new($"Budget with ID {budgetId} not found or access denied") }); + } + + // Parse operations + var operations = new List(); + foreach (var op in request.Operations) + { + var parseResult = mapper.FromRequest(op); + if (parseResult.IsFailed) + { + return BadRequest(parseResult.Errors); + } + operations.Add(parseResult.Value); + } + + // Parse transfer confidence level + DetectionAccuracy? transferConfidenceLevel = null; + if (!string.IsNullOrWhiteSpace(request.TransferConfidenceLevel)) + { + var accuracyResult = mapper.ParseDetectionAccuracy(request.TransferConfidenceLevel); + if (accuracyResult.IsFailed) + { + return BadRequest(accuracyResult.Errors); + } + transferConfidenceLevel = accuracyResult.Value; + } + + // Update budget version for optimistic concurrency + budget.Version = request.BudgetVersion; + + var options = new ImportOptions(transferConfidenceLevel); + + async IAsyncEnumerable GetOperationsAsync() + { + foreach (var op in operations) + { + yield return op; + } + await Task.CompletedTask; + } + + var command = new ImportOperationsCommand( + GetOperationsAsync(), + budget, + options + ); + + var result = await mediator.Send(command, ct); + + if (result.IsSuccess) + { + var response = new ImportResultResponse( + result.Operations.Select(mapper.ToResponse).ToList(), + result.Duplicates.Select(group => group.Select(mapper.ToResponse).ToList()).ToList(), + result.Reasons.Select(e => e.Message).ToList() + ); + return Ok(response); + } + + return BadRequest(result.Errors); + } + + /// + /// Updates existing operations in a budget + /// + /// Budget ID from route + /// Update operations request + /// Cancellation token + /// Update result with success/failure details + [HttpPut] + [Consumes("application/json", "application/yaml", "text/yaml")] + [ProducesResponseType(typeof(UpdateResultResponse), 200)] + [ProducesResponseType(typeof(IEnumerable), 400)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async Task UpdateOperations( + [FromRoute] Guid budgetId, + [FromBody] UpdateOperationsRequest request, + CancellationToken ct) + { + // Validate budget access + var budgets = await mediator.Send(new ListOwnedBudgetsQuery(), ct); + var budget = budgets.FirstOrDefault(b => b.Id == budgetId); + + if (budget == null) + { + return NotFound(new List { new($"Budget with ID {budgetId} not found or access denied") }); + } + + // Update budget version for optimistic concurrency + budget.Version = request.BudgetVersion; + + // Parse operations + var operations = new List(); + foreach (var op in request.Operations) + { + var parseResult = mapper.FromRequest(op, budget); + if (parseResult.IsFailed) + { + return BadRequest(parseResult.Errors); + } + operations.Add(parseResult.Value); + } + + // Parse transfer confidence level + DetectionAccuracy? transferConfidenceLevel = null; + if (!string.IsNullOrWhiteSpace(request.TransferConfidenceLevel)) + { + var accuracyResult = mapper.ParseDetectionAccuracy(request.TransferConfidenceLevel); + if (accuracyResult.IsFailed) + { + return BadRequest(accuracyResult.Errors); + } + transferConfidenceLevel = accuracyResult.Value; + } + + // Parse tagging mode + if (!Enum.TryParse(request.TaggingMode, true, out var taggingMode)) + { + return BadRequest(new List { new($"Invalid TaggingMode value: {request.TaggingMode}") }); + } + + var options = new UpdateOptions(transferConfidenceLevel, taggingMode); + + async IAsyncEnumerable GetOperationsAsync() + { + foreach (var op in operations) + { + yield return op; + } + await Task.CompletedTask; + } + + var command = new UpdateOperationsCommand( + GetOperationsAsync(), + budget, + options + ); + + var result = await mediator.Send(command, ct); + + if (result.IsSuccess) + { + var response = new UpdateResultResponse( + result.Operations.Select(mapper.ToResponse).ToList(), + result.Reasons.Select(e => e.Message).ToList() + ); + return Ok(response); + } + + return BadRequest(result.Errors); + } + + /// + /// Removes operations matching the specified criteria from a specific budget + /// + /// Budget ID from route + /// Remove operations request with criteria expression + /// Cancellation token + /// Success or error details + [HttpDelete] + [Consumes("application/json", "application/yaml", "text/yaml")] + [ProducesResponseType(204)] + [ProducesResponseType(typeof(IEnumerable), 400)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async Task RemoveOperations( + [FromRoute] Guid budgetId, + [FromBody] RemoveOperationsRequest request, + CancellationToken ct) + { + // Validate budget access + var budgets = await mediator.Send(new ListOwnedBudgetsQuery(), ct); + var budget = budgets.FirstOrDefault(b => b.Id == budgetId); + + if (budget == null) + { + return NotFound(new List { new($"Budget with ID {budgetId} not found or access denied") }); + } + + // Parse criteria expression + var criteriaResult = parser.ParseUnaryPredicate(request.Criteria); + if (criteriaResult.IsFailed) + { + return BadRequest(criteriaResult.Errors); + } + + var command = new RemoveOperationsCommand(criteriaResult.Value.AsExpression()); + var result = await mediator.Send(command, ct); + + if (result.IsSuccess) + { + return NoContent(); + } + + return BadRequest(result.Errors); + } +} diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Exceptions/HttpException.cs b/src/Controllers/NVs.Budget.Controllers.Web/Exceptions/HttpException.cs new file mode 100644 index 00000000..d9ef6f7b --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Exceptions/HttpException.cs @@ -0,0 +1,77 @@ +using FluentResults; + +namespace NVs.Budget.Controllers.Web.Exceptions; + +public abstract class HttpException : Exception +{ + public int StatusCode { get; } + public IEnumerable Errors { get; } + + protected HttpException(int statusCode, string message) : base(message) + { + StatusCode = statusCode; + Errors = new List { new Error(message) }; + } + + protected HttpException(int statusCode, IEnumerable errors) : base(string.Join(", ", errors.Select(e => e.Message))) + { + StatusCode = statusCode; + Errors = errors; + } +} + +public class BadRequestException : HttpException +{ + public BadRequestException(string message) : base(400, message) + { + } + + public BadRequestException(IEnumerable errors) : base(400, errors) + { + } +} + +public class NotFoundException : HttpException +{ + public NotFoundException(string message) : base(404, message) + { + } + + public NotFoundException(IEnumerable errors) : base(404, errors) + { + } +} + +public class UnauthorizedException : HttpException +{ + public UnauthorizedException(string message) : base(401, message) + { + } + + public UnauthorizedException(IEnumerable errors) : base(401, errors) + { + } +} + +public class ForbiddenException : HttpException +{ + public ForbiddenException(string message) : base(403, message) + { + } + + public ForbiddenException(IEnumerable errors) : base(403, errors) + { + } +} + +public class ConflictException : HttpException +{ + public ConflictException(string message) : base(409, message) + { + } + + public ConflictException(IEnumerable errors) : base(409, errors) + { + } +} + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Middleware/ExceptionHandlingMiddleware.cs b/src/Controllers/NVs.Budget.Controllers.Web/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 00000000..168602ff --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Text.Json; +using FluentResults; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using NVs.Budget.Controllers.Web.Exceptions; + +namespace NVs.Budget.Controllers.Web.Middleware; + +public class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (HttpException httpEx) + { + _logger.LogWarning(httpEx, "HTTP exception occurred: {Message}", httpEx.Message); + await HandleHttpExceptionAsync(context, httpEx); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception occurred: {Message}", ex.Message); + await HandleUnhandledExceptionAsync(context, ex); + } + } + + private static async Task HandleHttpExceptionAsync(HttpContext context, HttpException exception) + { + context.Response.StatusCode = exception.StatusCode; + context.Response.ContentType = "application/json"; + + var errors = exception.Errors.Select(e => new ErrorResponse(e.Message, e.Metadata)).ToList(); + var json = JsonSerializer.Serialize(errors, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + await context.Response.WriteAsync(json); + } + + private static async Task HandleUnhandledExceptionAsync(HttpContext context, Exception exception) + { + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/json"; + + var errors = new List + { + new("An internal server error occurred. Please try again later.", new Dictionary()) + }; + + var json = JsonSerializer.Serialize(errors, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + await context.Response.WriteAsync(json); + } +} + +public record ErrorResponse(string Message, Dictionary Metadata); + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs new file mode 100644 index 00000000..23e7bf71 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs @@ -0,0 +1,66 @@ +using NVs.Budget.Application.Contracts.Options; + +namespace NVs.Budget.Controllers.Web.Models; + +public record OperationResponse( + Guid Id, + string Version, + DateTime Timestamp, + MoneyResponse Amount, + string Description, + Guid BudgetId, + IReadOnlyCollection Tags, + Dictionary? Attributes +); + +public record MoneyResponse( + decimal Value, + string CurrencyCode +); + +public record UnregisteredOperationRequest( + DateTime Timestamp, + MoneyResponse Amount, + string Description, + Dictionary? Attributes +); + +public record UpdateOperationRequest( + Guid Id, + string Version, + DateTime Timestamp, + MoneyResponse Amount, + string Description, + IReadOnlyCollection Tags, + Dictionary? Attributes +); + +public record ImportOperationsRequest( + string BudgetVersion, + IReadOnlyCollection Operations, + string? TransferConfidenceLevel +); + +public record UpdateOperationsRequest( + string BudgetVersion, + IReadOnlyCollection Operations, + string? TransferConfidenceLevel, + string TaggingMode +); + +public record RemoveOperationsRequest( + string Criteria +); + +// Result response models +public record ImportResultResponse( + IReadOnlyCollection RegisteredOperations, + IReadOnlyCollection> Duplicates, + IReadOnlyCollection Errors +); + +public record UpdateResultResponse( + IReadOnlyCollection UpdatedOperations, + IReadOnlyCollection Errors +); + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Utils/OperationMapper.cs b/src/Controllers/NVs.Budget.Controllers.Web/Utils/OperationMapper.cs new file mode 100644 index 00000000..dd17532e --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Utils/OperationMapper.cs @@ -0,0 +1,103 @@ +using FluentResults; +using NMoneys; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Controllers.Web.Models; +using NVs.Budget.Domain.ValueObjects; + +namespace NVs.Budget.Controllers.Web.Utils; + +public class OperationMapper +{ + public OperationResponse ToResponse(TrackedOperation operation) + { + return new OperationResponse( + operation.Id, + operation.Version ?? string.Empty, + operation.Timestamp, + new MoneyResponse(operation.Amount.Amount, operation.Amount.CurrencyCode.ToString()), + operation.Description, + operation.Budget.Id, + operation.Tags.Select(t => t.Value).ToList(), + operation.Attributes.Count > 0 ? new Dictionary(operation.Attributes) : null + ); + } + + public Result FromRequest(UnregisteredOperationRequest request) + { + var moneyResult = ParseMoney(request.Amount); + if (moneyResult.IsFailed) + { + return Result.Fail(moneyResult.Errors); + } + + var attributes = request.Attributes != null + ? new Dictionary(request.Attributes) + : null; + + return Result.Ok(new UnregisteredOperation( + request.Timestamp, + moneyResult.Value, + request.Description, + attributes + )); + } + + public Result FromRequest(UpdateOperationRequest request, TrackedBudget budget) + { + var moneyResult = ParseMoney(request.Amount); + if (moneyResult.IsFailed) + { + return Result.Fail(moneyResult.Errors); + } + + var tags = request.Tags.Select(t => new Tag(t)).ToList(); + var attributes = request.Attributes != null + ? new Dictionary(request.Attributes) + : null; + + var operation = new TrackedOperation( + request.Id, + request.Timestamp, + moneyResult.Value, + request.Description, + budget, + tags, + attributes + ) + { + Version = request.Version + }; + + return Result.Ok(operation); + } + + public Result ParseDetectionAccuracy(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return Result.Ok(default); + } + + if (!Enum.TryParse(value, true, out var accuracy)) + { + return Result.Fail($"Invalid DetectionAccuracy value: {value}"); + } + + return Result.Ok(accuracy); + } + + private Result ParseMoney(MoneyResponse moneyResponse) + { + try + { + var currency = Currency.Get(moneyResponse.CurrencyCode); + var money = new Money(moneyResponse.Value, currency); + return Result.Ok(money); + } + catch (Exception ex) + { + return Result.Fail($"Invalid money value: {ex.Message}"); + } + } +} + diff --git a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs index 1422380c..e1f61ead 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.OpenApi.Models; using NVs.Budget.Controllers.Web.Filters; using NVs.Budget.Controllers.Web.Formatters; +using NVs.Budget.Controllers.Web.Middleware; using NVs.Budget.Controllers.Web.Utils; using Swashbuckle.AspNetCore.SwaggerGen; using YamlDotNet.Serialization; @@ -28,6 +29,7 @@ public static IServiceCollection AddWebControllers(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddEndpointsApiExplorer(); services.AddSwaggerGen(o => @@ -96,6 +98,9 @@ public static IServiceCollection AddWebControllers(this IServiceCollection servi public static WebApplication UseWebControllers(this WebApplication app, bool useSwagger) { + // Add exception handling middleware first to catch all exceptions + app.UseMiddleware(); + if (useSwagger) { app.UseSwagger(); diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts index a76292fd..58a10884 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts @@ -2,10 +2,14 @@ import { Routes } from '@angular/router'; import { NewBudgetComponent } from './budget/new-budget/new-budget.component'; import { BudgetDetailComponent } from './budget/budget-detail/budget-detail.component'; import { ReadingSettingsComponent } from './budget/reading-settings/reading-settings.component'; +import { ImportOperationsComponent } from './operations/import-operations/import-operations.component'; +import { OperationsListComponent } from './operations/operations-list/operations-list.component'; import { IndexComponent } from './index/index.component'; export const routes: Routes = [ { path: 'budget/new', component: NewBudgetComponent }, + { path: 'budget/:budgetId/operations/import', component: ImportOperationsComponent }, + { path: 'budget/:budgetId/operations', component: OperationsListComponent }, { path: 'budget/:budgetId/reading-settings', component: ReadingSettingsComponent }, { path: 'budget/:budgetId', component: BudgetDetailComponent }, { path: '', component: IndexComponent } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html index 39cb9fb8..42eaf56a 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.html @@ -22,6 +22,14 @@

{{ budget.name }}

(click)="navigateToReadingSettings()"> File Reading Settings + + + @if (csvContent) { + ✓ File loaded + } + + + +
+ + + + +

+ Specify the confidence level for transfer detection: Exact, Likely, or leave empty for default. +

+
+ +
+ + +
+ + + @if (importResult) { +
+

Import Results

+
+
+ Registered: + {{ importResult.registered }} +
+
+ Duplicates: + {{ importResult.duplicates }} +
+ @if (importResult.errors.length > 0) { +
+ Errors: + {{ importResult.errors.length }} +
+ } +
+ + @if (importResult.errors.length > 0) { +
+

Errors:

+
    + @for (error of importResult.errors; track $index) { +
  • {{ error }}
  • + } +
+
+ } +
+ } + } @else { +
Loading budget...
+ } + + + + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less new file mode 100644 index 00000000..5fe145c7 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less @@ -0,0 +1,138 @@ +.import-container { + padding: 2rem; + max-width: 900px; + margin: 0 auto; +} + +.import-card { + padding: 2rem; +} + +.budget-info { + margin: 1.5rem 0; + padding: 1rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + + p { + margin: 0.5rem 0; + } + + code { + background: var(--tui-base-01); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-family: monospace; + } +} + +.import-form { + margin-top: 2rem; +} + +.form-section { + margin-bottom: 2rem; + + h3 { + margin-bottom: 1rem; + } +} + +.help-text { + margin: 0.5rem 0; + color: var(--tui-text-02); + font-size: 0.875rem; +} + +.file-input-container { + display: flex; + align-items: center; + gap: 1rem; + + .file-input { + display: none; + } + + .file-label { + cursor: pointer; + } + + .file-selected { + color: var(--tui-success-fill); + font-weight: 600; + } +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; +} + +.import-result { + margin-top: 2rem; + padding: 1.5rem; + background: var(--tui-base-02); + border-radius: 0.5rem; +} + +.result-stats { + display: flex; + gap: 2rem; + margin: 1rem 0; + flex-wrap: wrap; +} + +.stat-item { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .stat-label { + font-size: 0.875rem; + color: var(--tui-text-02); + } + + .stat-value { + font-size: 1.5rem; + font-weight: 700; + } + + &.success .stat-value { + color: var(--tui-success-fill); + } + + &.warning .stat-value { + color: var(--tui-warning-fill); + } + + &.error .stat-value { + color: var(--tui-error-fill); + } +} + +.errors-section { + margin-top: 1.5rem; + + h4 { + color: var(--tui-error-fill); + margin-bottom: 0.5rem; + } + + ul { + margin: 0; + padding-left: 1.5rem; + + li { + margin: 0.25rem 0; + color: var(--tui-error-fill); + } + } +} + +.loading-placeholder { + text-align: center; + padding: 2rem; + color: var(--tui-text-02); +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts new file mode 100644 index 00000000..9c36f425 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts @@ -0,0 +1,224 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { switchMap, catchError, of } from 'rxjs'; +import { OperationsApiService } from '../operations-api.service'; +import { BudgetApiService } from '../../budget/budget-api.service'; +import { BudgetResponse, UnregisteredOperationRequest, ImportOperationsRequest } from '../../budget/models'; +import { + TuiButton, + TuiDialogService, + TuiLoader, + TuiTitle, + TuiTextfield, + TuiLabel +} from '@taiga-ui/core'; +import { TuiCardLarge } from '@taiga-ui/layout'; + +@Component({ + selector: 'app-import-operations', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TuiButton, + TuiLoader, + TuiTextfield, + TuiLabel, + TuiCardLarge, + TuiTitle + ], + templateUrl: './import-operations.component.html', + styleUrls: ['./import-operations.component.less'] +}) +export class ImportOperationsComponent implements OnInit { + budgetId!: string; + budget: BudgetResponse | null = null; + isLoading = false; + + importForm!: FormGroup; + csvContent: string = ''; + importResult: { registered: number; duplicates: number; errors: string[] } | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private operationsApi: OperationsApiService, + private budgetApi: BudgetApiService, + private fb: FormBuilder, + private dialogService: TuiDialogService + ) {} + + ngOnInit(): void { + this.budgetId = this.route.snapshot.params['budgetId']; + + this.importForm = this.fb.group({ + transferConfidenceLevel: [''] + }); + + this.loadBudget(); + } + + loadBudget(): void { + this.isLoading = true; + this.budgetApi.getBudgetById(this.budgetId).subscribe({ + next: (budget) => { + this.budget = budget || null; + this.isLoading = false; + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to load budget'); + } + }); + } + + onFileSelected(event: Event): void { + const input = event.target as HTMLInputElement; + if (!input.files || input.files.length === 0) return; + + const file = input.files[0]; + const reader = new FileReader(); + + reader.onload = (e) => { + this.csvContent = e.target?.result as string; + }; + + reader.onerror = () => { + this.showError('Failed to read file'); + }; + + reader.readAsText(file); + } + + importCsv(): void { + if (!this.csvContent || !this.budget) { + this.showError('Please select a CSV file first'); + return; + } + + this.isLoading = true; + this.importResult = null; + + // Parse CSV content + const operations = this.parseCsv(this.csvContent); + if (operations.length === 0) { + this.isLoading = false; + this.showError('No valid operations found in CSV'); + return; + } + + const request: ImportOperationsRequest = { + budgetVersion: this.budget.version, + operations: operations, + transferConfidenceLevel: this.importForm.value.transferConfidenceLevel || undefined + }; + + this.operationsApi.importOperations(this.budgetId, request).subscribe({ + next: (result) => { + this.isLoading = false; + this.importResult = { + registered: result.registeredOperations.length, + duplicates: result.duplicates.length, + errors: result.errors + }; + + if (result.errors.length === 0) { + this.showSuccess(`Successfully imported ${result.registeredOperations.length} operations`); + this.operationsApi.triggerRefresh(this.budgetId); + } else { + this.showError(`Import completed with errors: ${result.errors.join('; ')}`); + } + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to import operations'); + } + }); + } + + parseCsv(content: string): UnregisteredOperationRequest[] { + const lines = content.split('\n').filter(line => line.trim()); + if (lines.length < 2) return []; // Need header + at least one row + + const header = lines[0].split(',').map(h => h.trim()); + const operations: UnregisteredOperationRequest[] = []; + + // Find column indices + const timestampIdx = header.findIndex(h => h.toLowerCase() === 'timestamp' || h.toLowerCase() === 'date'); + const amountIdx = header.findIndex(h => h.toLowerCase() === 'amount'); + const currencyIdx = header.findIndex(h => h.toLowerCase() === 'currency'); + const descriptionIdx = header.findIndex(h => h.toLowerCase() === 'description'); + + if (timestampIdx === -1 || amountIdx === -1 || currencyIdx === -1 || descriptionIdx === -1) { + this.showError('CSV must have columns: timestamp, amount, currency, description'); + return []; + } + + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(',').map(v => v.trim()); + + try { + const operation: UnregisteredOperationRequest = { + timestamp: new Date(values[timestampIdx]).toISOString(), + amount: { + value: parseFloat(values[amountIdx]), + currencyCode: values[currencyIdx] + }, + description: values[descriptionIdx], + attributes: {} + }; + + // Add remaining columns as attributes + for (let j = 0; j < header.length; j++) { + if (j !== timestampIdx && j !== amountIdx && j !== currencyIdx && j !== descriptionIdx) { + operation.attributes![header[j]] = values[j]; + } + } + + operations.push(operation); + } catch (error) { + console.warn(`Skipping invalid row ${i + 1}:`, error); + } + } + + return operations; + } + + viewOperations(): void { + this.router.navigate(['/budget', this.budgetId, 'operations']); + } + + private handleError(error: any, defaultMessage: string): void { + let errorMessage = defaultMessage; + + if (error.status === 400 && Array.isArray(error.error)) { + const errors = error.error as any[]; + errorMessage = errors.map(e => e.message || e).join('; '); + } else if (error.error?.message) { + errorMessage = error.error.message; + } + + this.showError(errorMessage); + } + + private showError(message: string): void { + this.dialogService.open(message, { + label: 'Error', + size: 'm', + closeable: true, + dismissible: true + }).subscribe(); + } + + private showSuccess(message: string): void { + this.dialogService.open(message, { + label: 'Success', + size: 's', + closeable: true, + dismissible: true + }).subscribe(); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts new file mode 100644 index 00000000..25307d7c --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { BehaviorSubject, Observable, startWith, switchMap } from 'rxjs'; +import { + OperationResponse, + ImportOperationsRequest, + UpdateOperationsRequest, + RemoveOperationsRequest, + ImportResultResponse, + UpdateResultResponse +} from '../budget/models'; +import { environment } from '../../environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class OperationsApiService { + public readonly baseUrl = environment.apiUrl + '/api/v0.1'; + private refresh$ = new BehaviorSubject(null); + + constructor(private http: HttpClient) {} + + /** + * Get all operations for a specific budget + */ + getOperations( + budgetId: string, + criteria?: string, + outputCurrency?: string, + excludeTransfers: boolean = false + ): Observable { + return this.refresh$.pipe( + startWith(undefined), + switchMap(() => { + let url = `${this.baseUrl}/budget/${budgetId}/operations`; + const params = new URLSearchParams(); + + if (criteria) { + params.append('criteria', criteria); + } + if (outputCurrency) { + params.append('outputCurrency', outputCurrency); + } + if (excludeTransfers) { + params.append('excludeTransfers', 'true'); + } + + const queryString = params.toString(); + if (queryString) { + url += `?${queryString}`; + } + + return this.http.get(url, { withCredentials: true }); + }) + ); + } + + /** + * Import operations into a budget + */ + importOperations(budgetId: string, request: ImportOperationsRequest): Observable { + const headers = new HttpHeaders().set('Content-Type', 'application/json'); + return this.http.post( + `${this.baseUrl}/budget/${budgetId}/operations/import`, + request, + { headers, withCredentials: true } + ); + } + + /** + * Update existing operations + */ + updateOperations(budgetId: string, request: UpdateOperationsRequest): Observable { + const headers = new HttpHeaders().set('Content-Type', 'application/json'); + return this.http.put( + `${this.baseUrl}/budget/${budgetId}/operations`, + request, + { headers, withCredentials: true } + ); + } + + /** + * Remove operations matching criteria + */ + removeOperations(budgetId: string, request: RemoveOperationsRequest): Observable { + const headers = new HttpHeaders().set('Content-Type', 'application/json'); + return this.http.request( + 'DELETE', + `${this.baseUrl}/budget/${budgetId}/operations`, + { + headers, + body: request, + withCredentials: true + } + ); + } + + /** + * Trigger refresh for operations list + */ + triggerRefresh(budgetId: string): void { + this.refresh$.next(budgetId); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html new file mode 100644 index 00000000..8f22101a --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -0,0 +1,176 @@ +
+
+
+

Operations

+
+ + +
+
+ + @if (budget) { +
+

Budget: {{ budget.name }}

+
+ } + + +
+

Filters

+
+ + + + + + + + + + +
+ +
+
+ +
+ + +
+
+ + +
+ @if (operations$ | async; as operations) { + @if (operations.length > 0) { +
+ @for (operation of operations; track operation.id) { +
+
+
+ {{ formatDate(operation.timestamp) }} + {{ operation.description }} +
+
+ {{ formatCurrency(operation.amount.value, operation.amount.currencyCode) }} +
+ +
+ + @if (expandedOperationId === operation.id) { +
+
+ ID: + {{ operation.id }} +
+
+ Version: + {{ operation.version }} +
+
+ Budget ID: + {{ operation.budgetId }} +
+ + @if (operation.tags.length > 0) { +
+ Tags: +
+ @for (tag of operation.tags; track tag) { + {{ tag }} + } +
+
+ } + + @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { +
+ Attributes: +
+ @for (key of getObjectKeys(operation.attributes!); track key) { +
+ {{ key }}: + {{ operation.attributes![key] }} +
+ } +
+
+ } +
+ } +
+ } +
+ +
+ Total: {{ operations.length }} operations +
+ } @else { +
+

No operations found.

+ +
+ } + } @else { + +
Loading operations...
+
+ } +
+
+
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less new file mode 100644 index 00000000..b6b4202b --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -0,0 +1,235 @@ +.operations-container { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +.operations-card { + padding: 2rem; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.header-actions { + display: flex; + gap: 1rem; +} + +.budget-info { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + + p { + margin: 0; + } +} + +.filters-section { + margin-bottom: 2rem; + padding: 1.5rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + + h3 { + margin-bottom: 1rem; + } +} + +.filters-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} + +.checkbox-field { + display: flex; + align-items: center; + padding-top: 1.5rem; + + label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + + input[type="checkbox"] { + width: 1.25rem; + height: 1.25rem; + cursor: pointer; + } + } +} + +.filter-actions { + display: flex; + gap: 1rem; +} + +.operations-section { + margin-top: 2rem; +} + +.operations-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.operation-card { + border: 1px solid var(--tui-base-04); + border-radius: 0.5rem; + overflow: hidden; + transition: box-shadow 0.2s; + + &:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } +} + +.operation-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + cursor: pointer; + gap: 1rem; + + &:hover { + background: var(--tui-base-02); + } +} + +.operation-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.operation-date { + font-size: 0.875rem; + color: var(--tui-text-02); +} + +.operation-description { + font-weight: 500; + color: var(--tui-text-01); +} + +.operation-amount { + font-size: 1.125rem; + font-weight: 700; + min-width: 120px; + text-align: right; + + &.positive { + color: var(--tui-success-fill); + } + + &.negative { + color: var(--tui-error-fill); + } +} + +.operation-details { + padding: 1rem; + border-top: 1px solid var(--tui-base-04); + background: var(--tui-base-01); +} + +.detail-row { + display: flex; + gap: 1rem; + margin-bottom: 0.75rem; + + &:last-child { + margin-bottom: 0; + } +} + +.detail-label { + font-weight: 600; + color: var(--tui-text-02); + min-width: 100px; +} + +.detail-value { + background: var(--tui-base-02); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.875rem; +} + +.tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.attributes-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.attribute-item { + display: flex; + gap: 0.5rem; + font-size: 0.875rem; + + .attr-key { + font-weight: 600; + color: var(--tui-text-02); + } + + .attr-value { + color: var(--tui-text-01); + } +} + +.operations-count { + margin-top: 1rem; + padding: 0.75rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + text-align: center; + font-weight: 600; +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + + p { + margin-bottom: 1.5rem; + color: var(--tui-text-02); + font-size: 1.125rem; + } +} + +.loading-placeholder { + text-align: center; + padding: 3rem 1rem; + color: var(--tui-text-02); +} + +// Make Object.keys available in template +:host { + ::ng-deep { + .Object { + display: none; + } + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts new file mode 100644 index 00000000..3876b216 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -0,0 +1,168 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable, catchError, of } from 'rxjs'; +import { OperationsApiService } from '../operations-api.service'; +import { BudgetApiService } from '../../budget/budget-api.service'; +import { BudgetResponse, OperationResponse } from '../../budget/models'; +import { + TuiButton, + TuiDialogService, + TuiLoader, + TuiTitle, + TuiTextfield, + TuiLabel, + TuiExpand +} from '@taiga-ui/core'; +import { TuiCardLarge } from '@taiga-ui/layout'; +import { TuiChip, TuiAccordion } from '@taiga-ui/kit'; + +@Component({ + selector: 'app-operations-list', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TuiButton, + TuiLoader, + TuiTextfield, + TuiLabel, + TuiCardLarge, + TuiTitle, + TuiChip, + TuiAccordion, + TuiExpand + ], + templateUrl: './operations-list.component.html', + styleUrls: ['./operations-list.component.less'] +}) +export class OperationsListComponent implements OnInit { + budgetId!: string; + budget: BudgetResponse | null = null; + operations$!: Observable; + isLoading = false; + + filterForm!: FormGroup; + expandedOperationId: string | null = null; + + readonly columns = ['timestamp', 'description', 'amount', 'tags', 'actions']; + + constructor( + private route: ActivatedRoute, + private router: Router, + private operationsApi: OperationsApiService, + private budgetApi: BudgetApiService, + private fb: FormBuilder, + private dialogService: TuiDialogService + ) {} + + ngOnInit(): void { + this.budgetId = this.route.snapshot.params['budgetId']; + + this.filterForm = this.fb.group({ + criteria: [''], + outputCurrency: [''], + excludeTransfers: [false] + }); + + this.loadBudget(); + this.loadOperations(); + } + + loadBudget(): void { + this.budgetApi.getBudgetById(this.budgetId).subscribe({ + next: (budget) => { + this.budget = budget || null; + }, + error: (error) => { + this.handleError(error, 'Failed to load budget'); + } + }); + } + + loadOperations(): void { + const formValue = this.filterForm.value; + this.operations$ = this.operationsApi.getOperations( + this.budgetId, + formValue.criteria || undefined, + formValue.outputCurrency || undefined, + formValue.excludeTransfers + ).pipe( + catchError(error => { + this.handleError(error, 'Failed to load operations'); + return of([]); + }) + ); + } + + applyFilters(): void { + this.loadOperations(); + } + + clearFilters(): void { + this.filterForm.reset({ + criteria: '', + outputCurrency: '', + excludeTransfers: false + }); + this.loadOperations(); + } + + toggleOperationDetails(operationId: string): void { + this.expandedOperationId = this.expandedOperationId === operationId ? null : operationId; + } + + formatCurrency(amount: number, currencyCode: string): string { + return `${amount.toFixed(2)} ${currencyCode}`; + } + + formatDate(timestamp: string): string { + return new Date(timestamp).toLocaleString(); + } + + navigateToImport(): void { + this.router.navigate(['/budget', this.budgetId, 'operations', 'import']); + } + + navigateToBudget(): void { + this.router.navigate(['/budget', this.budgetId]); + } + + private handleError(error: any, defaultMessage: string): void { + let errorMessage = defaultMessage; + + if (error.status === 400 && Array.isArray(error.error)) { + const errors = error.error as any[]; + errorMessage = errors.map(e => e.message || e).join('; '); + } else if (error.error?.message) { + errorMessage = error.error.message; + } + + this.showError(errorMessage); + } + + private showError(message: string): void { + this.dialogService.open(message, { + label: 'Error', + size: 'm', + closeable: true, + dismissible: true + }).subscribe(); + } + + private showSuccess(message: string): void { + this.dialogService.open(message, { + label: 'Success', + size: 's', + closeable: true, + dismissible: true + }).subscribe(); + } + + // Helper method for template to access Object.keys + getObjectKeys(obj: any): string[] { + return obj ? Object.keys(obj) : []; + } +} + From b14f6e2c592e605514959ac50e48df4e36d65e98 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Thu, 16 Oct 2025 12:49:31 +0300 Subject: [PATCH 084/156] feat: implement ICsvFileReader with pattern-based CSV parsing and validation - Add CsvFileReader class implementing ICsvFileReader interface - Support pattern-based column mapping (e.g., {0}, {1}, {0} and {1}) - Implement row-by-row validation using ValidationRules - Add comprehensive error handling with row number metadata - Create custom error types: NoFieldOptionsProvidedFor, AttributeParsingError, RowNotParsedError, ValidationFailedError, ConversionError - Register CsvFileReader in DI container - Add 22 comprehensive unit tests covering: - Simple and complex pattern combinations - Attributes mapping - Validation rules (Equals/NotEquals conditions) - Error scenarios (invalid dates, amounts, currencies, missing fields) - Multiple cultures (fr-FR, ru-RU, ja-JP, zh-CN) with proper delimiters - Multiple encodings (UTF-8, UTF-16, UTF-32, ISO-8859-1) - International text (French, Russian, Japanese, Chinese, Arabic) - Edge cases (empty rows, patterns without placeholders) - All tests passing (25/25 including repository tests) --- .../ICsvFileReader.cs | 6 +- .../CsvFileReaderShould.cs | 861 ++++++++++++++++++ .../CsvExtensions.cs | 3 +- .../CsvFileReader.cs | 260 ++++++ .../Errors/AttributeParsingError.cs | 11 + .../Errors/ExceptionalError.cs | 11 + .../Errors/NoFieldOptionsProvidedFor.cs | 11 + .../Errors/RowNotParsedError.cs | 11 + .../Errors/ValidationFailedError.cs | 11 + ...NVs.Budget.Infrastructure.Files.CSV.csproj | 1 + 10 files changed, 1182 insertions(+), 4 deletions(-) create mode 100644 src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/CsvFileReaderShould.cs create mode 100644 src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvFileReader.cs create mode 100644 src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/AttributeParsingError.cs create mode 100644 src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/ExceptionalError.cs create mode 100644 src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/NoFieldOptionsProvidedFor.cs create mode 100644 src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/RowNotParsedError.cs create mode 100644 src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/ValidationFailedError.cs diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Contracts/ICsvFileReader.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Contracts/ICsvFileReader.cs index ac8121c5..06df244e 100644 --- a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Contracts/ICsvFileReader.cs +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Contracts/ICsvFileReader.cs @@ -1,9 +1,9 @@ -using NVs.Budget.Application.Contracts.Entities.Budgeting; +using FluentResults; +using NVs.Budget.Application.Contracts.Entities.Budgeting; namespace NVs.Budget.Infrastructure.Files.CSV.Contracts; public interface ICsvFileReader { - IAsyncEnumerable ReadUntrackedOperations(StreamReader reader, FileReadingSetting config, CancellationToken ct); - IAsyncEnumerable ReadTrackedOperations(StreamReader reader, CancellationToken ct); + IAsyncEnumerable> ReadUntrackedOperations(StreamReader reader, FileReadingSetting config, CancellationToken ct); }; diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/CsvFileReaderShould.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/CsvFileReaderShould.cs new file mode 100644 index 00000000..d8fb0922 --- /dev/null +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/CsvFileReaderShould.cs @@ -0,0 +1,861 @@ +using System.Globalization; +using System.Text; +using FluentAssertions; +using FluentResults.Extensions.FluentAssertions; +using NMoneys; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Infrastructure.Files.CSV.Contracts; + +namespace NVs.Budget.Infrastructure.Files.CSV.Tests; + +public class CsvFileReaderShould +{ + private readonly CsvFileReader _reader; + + public CsvFileReaderShould() + { + _reader = new CsvFileReader(); + } + + private static async Task> ToListAsync(IAsyncEnumerable source) + { + var list = new List(); + await foreach (var item in source) + { + list.Add(item); + } + return list; + } + + [Fact] + public async Task ReadSimpleOperationsSuccessfully() + { + // Arrange + var csv = """ + 2024-01-15,100.50,USD,Coffee shop + 2024-01-16,200.00,USD,Grocery store + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + + results[0].Value.Timestamp.Should().Be(new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc)); + results[0].Value.Amount.Should().Be(new Money(100.50m, Currency.Usd)); + results[0].Value.Description.Should().Be("Coffee shop"); + + results[1].Value.Timestamp.Should().Be(new DateTime(2024, 1, 16, 0, 0, 0, DateTimeKind.Utc)); + results[1].Value.Amount.Should().Be(new Money(200.00m, Currency.Usd)); + results[1].Value.Description.Should().Be("Grocery store"); + } + + [Fact] + public async Task ReadOperationsWithPatternCombinations() + { + // Arrange + var csv = """ + 2024-01-15,100,50,USD,Coffee,shop + 2024-01-16,200,00,EUR,Grocery,store + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}.{2}", + ["Amount.CurrencyCode"] = "{3}", + [nameof(UnregisteredOperation.Description)] = "{4} {5}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + + results[0].Value.Amount.Should().Be(new Money(100.50m, Currency.Usd)); + results[0].Value.Description.Should().Be("Coffee shop"); + + results[1].Value.Amount.Should().Be(new Money(200.00m, Currency.Eur)); + results[1].Value.Description.Should().Be("Grocery store"); + } + + [Fact] + public async Task ReadOperationsWithAttributes() + { + // Arrange + var csv = """ + 2024-01-15,100.50,USD,Coffee shop,Card,Personal + 2024-01-16,200.00,USD,Grocery store,Cash,Business + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary + { + ["PaymentMethod"] = "{4}", + ["Category"] = "{5}" + }, + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + + results[0].Value.Attributes.Should().NotBeNull(); + results[0].Value.Attributes!["PaymentMethod"].Should().Be("Card"); + results[0].Value.Attributes["Category"].Should().Be("Personal"); + + results[1].Value.Attributes.Should().NotBeNull(); + results[1].Value.Attributes!["PaymentMethod"].Should().Be("Cash"); + results[1].Value.Attributes["Category"].Should().Be("Business"); + } + + [Fact] + public async Task SkipRowsThatFailValidation_Equals() + { + // Arrange + var csv = """ + header,row,to,skip + 2024-01-15,100.50,USD,Coffee shop + 2024-01-16,200.00,USD,Grocery store + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: new[] + { + new ValidationRule( + Pattern: "{0}", + Condition: ValidationRule.ValidationCondition.NotEquals, + Value: "header", + ErrorMessage: "" + ) + } + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + } + + [Fact] + public async Task ReturnFailureForRowsWithValidationError() + { + // Arrange + var csv = """ + invalid,100.50,USD,Coffee shop + 2024-01-16,200.00,USD,Grocery store + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: new[] + { + new ValidationRule( + Pattern: "{0}", + Condition: ValidationRule.ValidationCondition.NotEquals, + Value: "invalid", + ErrorMessage: "Invalid row detected" + ) + } + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results[0].Should().BeFailure(); + results[0].Errors.Should().ContainSingle(e => e.Message == "Unable to parse row!"); + + results[1].Should().BeSuccess(); + } + + [Fact] + public async Task ReturnFailureForInvalidDateFormat() + { + // Arrange + var csv = """ + not-a-date,100.50,USD,Coffee shop + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().ContainSingle(); + results[0].Should().BeFailure(); + results[0].Errors.Should().ContainSingle(e => e.Message == "Unable to parse row!"); + } + + [Fact] + public async Task ReturnFailureForInvalidAmountFormat() + { + // Arrange + var csv = """ + 2024-01-15,not-a-number,USD,Coffee shop + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().ContainSingle(); + results[0].Should().BeFailure(); + results[0].Errors.Should().ContainSingle(e => e.Message == "Unable to parse row!"); + } + + [Fact] + public async Task ReturnFailureForInvalidCurrencyCode() + { + // Arrange + var csv = """ + 2024-01-15,100.50,INVALID,Coffee shop + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().ContainSingle(); + results[0].Should().BeFailure(); + results[0].Errors.Should().ContainSingle(e => e.Message == "Unable to parse row!"); + } + + [Fact] + public async Task ReturnFailureForMissingRequiredField() + { + // Arrange + var csv = """ + 2024-01-15,100.50,USD,Coffee shop + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + // Missing Timestamp field + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().ContainSingle(); + results[0].Should().BeFailure(); + results[0].Errors[0].Reasons.Should().ContainSingle(e => e.Message.Contains("No field options provided")); + } + + [Fact] + public async Task HandleEmptyRows() + { + // Arrange + var csv = """ + 2024-01-15,100.50,USD,Coffee shop + + 2024-01-16,200.00,USD,Grocery store + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + } + + [Fact] + public async Task ReadOperationsWithLocalDateTimeKind() + { + // Arrange - testing with Local DateTimeKind + var csv = """ + 2024-01-15,1234.56,EUR,Coffee shop + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Local, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().ContainSingle(); + results[0].Should().BeSuccess(); + results[0].Value.Timestamp.Kind.Should().Be(DateTimeKind.Local); + results[0].Value.Amount.Should().Be(new Money(1234.56m, Currency.Eur)); + } + + [Fact] + public async Task HandleComplexValidationRules() + { + // Arrange + var csv = """ + expense,2024-01-15,100.50,USD,Coffee shop + income,2024-01-16,500.00,USD,Salary + expense,2024-01-17,200.00,USD,Grocery store + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{1}", + [nameof(UnregisteredOperation.Amount)] = "{2}", + ["Amount.CurrencyCode"] = "{3}", + [nameof(UnregisteredOperation.Description)] = "{4}" + }, + Attributes: new Dictionary + { + ["Type"] = "{0}" + }, + Validation: new[] + { + new ValidationRule( + Pattern: "{0}", + Condition: ValidationRule.ValidationCondition.Equals, + Value: "expense", + ErrorMessage: "" + ) + } + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); // Only expense rows should be processed + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + results[0].Value.Description.Should().Be("Coffee shop"); + results[1].Value.Description.Should().Be("Grocery store"); + } + + [Fact] + public async Task IncludeRowNumberInErrorMetadata() + { + // Arrange + var csv = """ + 2024-01-15,100.50,USD,Coffee shop + invalid-date,200.00,USD,Grocery store + 2024-01-17,300.00,USD,Restaurant + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(3); + results[0].Should().BeSuccess(); + results[1].Should().BeFailure(); + results[1].Errors[0].Metadata.Should().ContainKey("row"); + results[1].Errors[0].Metadata["row"].Should().Be(2); + results[2].Should().BeSuccess(); + } + + [Fact] + public async Task HandlePatternWithoutPlaceholders() + { + // Arrange + var csv = """ + 2024-01-15,100.50,USD + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "Default description" // No placeholder + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().ContainSingle(); + results[0].Should().BeSuccess(); + results[0].Value.Description.Should().Be("Default description"); + } + + [Fact] + public async Task ReadOperationsWithFrenchCulture() + { + // Arrange - French culture (fr-FR): uses semicolon delimiter, comma as decimal separator + var csv = """ + 2024-01-15;1234,56;EUR;Café + 2024-01-16;200,00;EUR;Résumé financier + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: new CultureInfo("fr-FR"), + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + results[0].Value.Timestamp.Should().Be(new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc)); + results[0].Value.Amount.Should().Be(new Money(1234.56m, Currency.Eur)); + results[0].Value.Description.Should().Be("Café"); + results[1].Value.Amount.Should().Be(new Money(200.00m, Currency.Eur)); + results[1].Value.Description.Should().Be("Résumé financier"); + } + + [Fact] + public async Task ReadOperationsWithRussianCulture() + { + // Arrange - Russian culture (ru-RU): uses semicolon delimiter, comma as decimal separator + var csv = """ + 2024-01-15;100,50;EUR;Магазин + 2024-01-16;250,75;EUR;Ресторан + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: new CultureInfo("ru-RU"), + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + results[0].Value.Timestamp.Should().Be(new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc)); + results[0].Value.Amount.Should().Be(new Money(100.50m, Currency.Eur)); + results[0].Value.Description.Should().Be("Магазин"); + results[1].Value.Amount.Should().Be(new Money(250.75m, Currency.Eur)); + results[1].Value.Description.Should().Be("Ресторан"); + } + + [Fact] + public async Task ReadOperationsWithJapaneseCulture() + { + // Arrange - Japanese culture (ja-JP): uses comma delimiter, period as decimal separator + var csv = """ + 2024-01-15,1000.00,JPY,コーヒー + 2024-01-16,5000.50,JPY,レストラン + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: new CultureInfo("ja-JP"), + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + results[0].Value.Timestamp.Should().Be(new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc)); + results[0].Value.Amount.Should().Be(new Money(1000m, Currency.Jpy)); + results[0].Value.Description.Should().Be("コーヒー"); + results[1].Value.Amount.Should().Be(new Money(5000.50m, Currency.Jpy)); + results[1].Value.Description.Should().Be("レストラン"); + } + + [Fact] + public async Task ReadOperationsWithUTF16Encoding() + { + // Arrange - UTF-16 encoded file + var csv = """ + 2024-01-15,100.50,USD,Coffee shop + 2024-01-16,200.00,USD,Store + """; + var stream = CreateStreamReader(csv, Encoding.Unicode); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.Unicode, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + } + + [Fact] + public async Task ReadOperationsWithUTF32Encoding() + { + // Arrange - UTF-32 encoded file with emoji + var csv = """ + 2024-01-15,100.50,USD,Coffee ☕ + 2024-01-16,200.00,USD,Restaurant 🍽️ + """; + var stream = CreateStreamReader(csv, Encoding.UTF32); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: Encoding.UTF32, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + results[0].Value.Description.Should().Be("Coffee ☕"); + results[1].Value.Description.Should().Be("Restaurant 🍽️"); + } + + [Fact] + public async Task ReadOperationsWithLatin1Encoding() + { + // Arrange - Latin1 (ISO-8859-1) encoding with special characters + var csv = """ + 2024-01-15,100.50,EUR,Café résumé + 2024-01-16,200.00,EUR,Naïve émigré + """; + var encoding = Encoding.GetEncoding("ISO-8859-1"); + var stream = CreateStreamReader(csv, encoding); + + var config = new FileReadingSetting( + Culture: CultureInfo.InvariantCulture, + Encoding: encoding, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + results[0].Value.Description.Should().Be("Café résumé"); + results[1].Value.Description.Should().Be("Naïve émigré"); + } + + [Fact] + public async Task ReadOperationsWithChineseCulture() + { + // Arrange - Chinese culture (zh-CN): uses comma delimiter, period as decimal separator + var csv = """ + 2024-01-15,1234.56,CNY,咖啡店 + 2024-01-16,200.00,CNY,超市 + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: new CultureInfo("zh-CN"), + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + results[0].Value.Timestamp.Should().Be(new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc)); + results[0].Value.Amount.Should().Be(new Money(1234.56m, Currency.Cny)); + results[0].Value.Description.Should().Be("咖啡店"); + results[1].Value.Amount.Should().Be(new Money(200.00m, Currency.Cny)); + results[1].Value.Description.Should().Be("超市"); + } + + [Fact] + public async Task ReadOperationsWithArabicCulture() + { + // Arrange - English (US) culture with Arabic text (demonstrating RTL text support) + var csv = """ + 2024-01-15,1234.50,USD,مقهى + 2024-01-16,200.00,USD,مطعم + """; + var stream = CreateStreamReader(csv); + + var config = new FileReadingSetting( + Culture: new CultureInfo("en-US"), + Encoding: Encoding.UTF8, + DateTimeKind: DateTimeKind.Utc, + Fields: new Dictionary + { + [nameof(UnregisteredOperation.Timestamp)] = "{0}", + [nameof(UnregisteredOperation.Amount)] = "{1}", + ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Description)] = "{3}" + }, + Attributes: new Dictionary(), + Validation: Array.Empty() + ); + + // Act + var results = await ToListAsync(_reader.ReadUntrackedOperations(stream, config, CancellationToken.None)); + + // Assert + results.Should().HaveCount(2); + results.Should().AllSatisfy(r => r.Should().BeSuccess()); + results[0].Value.Timestamp.Should().Be(new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc)); + results[0].Value.Amount.Should().Be(new Money(1234.50m, Currency.Usd)); + results[0].Value.Description.Should().Be("مقهى"); + results[1].Value.Amount.Should().Be(new Money(200.00m, Currency.Usd)); + results[1].Value.Description.Should().Be("مطعم"); + } + + private static StreamReader CreateStreamReader(string content, Encoding? encoding = null) + { + encoding ??= Encoding.UTF8; + var bytes = encoding.GetBytes(content); + var stream = new MemoryStream(bytes); + return new StreamReader(stream, encoding); + } +} + diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs index f9f22762..94f15d60 100644 --- a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvExtensions.cs @@ -18,7 +18,8 @@ public static IServiceCollection AddCsvFiles(this IServiceCollection services, s services.AddDbContext(o => o.UseNpgsql(connectionString)) .AddTransient>() - .AddScoped(); + .AddScoped() + .AddScoped(); return services; } } diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvFileReader.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvFileReader.cs new file mode 100644 index 00000000..a74f8e46 --- /dev/null +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvFileReader.cs @@ -0,0 +1,260 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.RegularExpressions; +using CsvHelper; +using CsvHelper.Configuration; +using FluentResults; +using NMoneys; +using NVs.Budget.Application.Contracts.Entities.Budgeting; +using NVs.Budget.Infrastructure.Files.CSV.Contracts; +using NVs.Budget.Infrastructure.Files.CSV.Errors; + +namespace NVs.Budget.Infrastructure.Files.CSV; + +internal partial class CsvFileReader : ICsvFileReader +{ + private static readonly Regex CellsIndexPattern = GenerateCellIndexPattern(); + + public async IAsyncEnumerable> ReadUntrackedOperations( + StreamReader reader, + FileReadingSetting config, + [EnumeratorCancellation] CancellationToken ct) + { + var csvConfig = new CsvConfiguration(config.Culture) + { + HasHeaderRecord = false, + MissingFieldFound = null + }; + + var parser = new CsvParser(reader, csvConfig, true); + var rowNumber = 0; + + while (await parser.ReadAsync()) + { + ct.ThrowIfCancellationRequested(); + rowNumber++; + + // Skip empty rows + if (string.IsNullOrWhiteSpace(parser.RawRecord)) + { + continue; + } + + // Validate row + var validationResult = ValidateRow(parser, config.Validation, rowNumber); + if (validationResult.IsFailed) + { + yield return validationResult.ToResult(); + continue; + } + + if (!validationResult.Value) + { + // Row doesn't meet validation criteria but it's not an error (e.g., it's a header row) + continue; + } + + // Parse row + var operationResult = ParseRow(parser, config, rowNumber); + yield return operationResult; + } + } + + private Result ValidateRow(IParser parser, IReadOnlyCollection validationRules, int rowNumber) + { + if (validationRules == null || validationRules.Count == 0) + { + return true; + } + + foreach (var rule in validationRules) + { + var valueResult = EvaluatePattern(parser, rule.Pattern); + if (valueResult.IsFailed) + { + return Result.Fail(new RowNotParsedError(rowNumber, valueResult.Errors)); + } + + var matches = rule.Condition switch + { + ValidationRule.ValidationCondition.Equals => valueResult.Value == rule.Value, + ValidationRule.ValidationCondition.NotEquals => valueResult.Value != rule.Value, + _ => throw new ArgumentOutOfRangeException(nameof(rule.Condition)) + }; + + if (!matches) + { + if (!string.IsNullOrEmpty(rule.ErrorMessage)) + { + return Result.Fail(new RowNotParsedError(rowNumber, + new List { new ValidationFailedError(rule.ErrorMessage) })); + } + return false; + } + } + + return true; + } + + private Result ParseRow(IParser parser, FileReadingSetting config, int rowNumber) + { + // Parse timestamp + var timestampResult = ParseField(parser, config.Fields, nameof(UnregisteredOperation.Timestamp), + s => DateTime.SpecifyKind(DateTime.Parse(s, config.Culture), config.DateTimeKind)); + if (timestampResult.IsFailed) + { + return BuildParseError(rowNumber, timestampResult.Errors); + } + + // Parse currency code + var currencyResult = ParseField(parser, config.Fields, "Amount.CurrencyCode", Currency.Get); + if (currencyResult.IsFailed) + { + return BuildParseError(rowNumber, currencyResult.Errors); + } + + // Parse amount + var moneyResult = ParseField(parser, config.Fields, nameof(UnregisteredOperation.Amount), + m => new Money(decimal.Parse(m, NumberStyles.Any, config.Culture), currencyResult.Value)); + if (moneyResult.IsFailed) + { + return BuildParseError(rowNumber, moneyResult.Errors); + } + + // Parse description + var descriptionResult = ParseField(parser, config.Fields, nameof(UnregisteredOperation.Description), s => s); + if (descriptionResult.IsFailed) + { + return BuildParseError(rowNumber, descriptionResult.Errors); + } + + // Parse attributes + var attributesResult = ParseAttributes(parser, config.Attributes); + if (attributesResult.IsFailed) + { + return BuildParseError(rowNumber, attributesResult.Errors); + } + + return new UnregisteredOperation( + timestampResult.Value, + moneyResult.Value, + descriptionResult.Value, + attributesResult.Value + ); + } + + private Result?> ParseAttributes(IParser parser, IReadOnlyDictionary attributePatterns) + { + if (attributePatterns == null || attributePatterns.Count == 0) + { + return Result.Ok?>(null); + } + + var attributes = new Dictionary(); + + foreach (var (name, pattern) in attributePatterns) + { + var valueResult = EvaluatePattern(parser, pattern); + if (valueResult.IsFailed) + { + return Result.Fail?>(new AttributeParsingError(name)); + } + + attributes.Add(name, valueResult.Value); + } + + return Result.Ok?>(attributes); + } + + private Result ParseField(IParser parser, IReadOnlyDictionary fields, string fieldName, Func convertFn) + { + if (!fields.TryGetValue(fieldName, out var pattern)) + { + return Result.Fail(new NoFieldOptionsProvidedFor(fieldName)); + } + + var rawValueResult = EvaluatePattern(parser, pattern); + if (rawValueResult.IsFailed) + { + return rawValueResult.ToResult(); + } + + try + { + return convertFn(rawValueResult.Value); + } + catch (Exception e) + { + var error = new ConversionError(e); + error.Metadata.Add(nameof(fieldName), fieldName); + return Result.Fail(error); + } + } + + private Result EvaluatePattern(IParser parser, string pattern) + { + var usedCells = CellsIndexPattern.Matches(pattern) + .Select(m => (match: m, index: int.Parse(m.Groups[1].Value))) + .ToList(); + + if (usedCells.Count == 0) + { + // No placeholders, return the pattern as-is + return pattern; + } + + try + { + var values = usedCells + .Select(m => m.index) + .Distinct() + .Select(i => (index: i, value: parser[i])) + .ToDictionary(v => v.index, v => v.value); + + var matchIndex = 0; + var strpos = 0; + var builder = new StringBuilder(); + + while (matchIndex < usedCells.Count) + { + var match = usedCells[matchIndex]; + + // Append any text before the placeholder + if (strpos < match.match.Index) + { + builder.Append(pattern, strpos, match.match.Index - strpos); + strpos = match.match.Index; + } + + // Append the cell value + builder.Append(values[match.index]); + strpos += match.match.Length; + matchIndex++; + } + + // Append any remaining text after the last placeholder + if (strpos < pattern.Length) + { + builder.Append(pattern, strpos, pattern.Length - strpos); + } + + return builder.ToString(); + } + catch (Exception e) + { + var error = new ConversionError(e); + error.Metadata.Add("Pattern", pattern); + return Result.Fail(error); + } + } + + private Result BuildParseError(int rowNumber, List errors) + { + return Result.Fail(new RowNotParsedError(rowNumber, errors)); + } + + [GeneratedRegex(@"\{(\d+)\}", RegexOptions.Compiled)] + private static partial Regex GenerateCellIndexPattern(); +} + diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/AttributeParsingError.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/AttributeParsingError.cs new file mode 100644 index 00000000..99ea9b9f --- /dev/null +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/AttributeParsingError.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace NVs.Budget.Infrastructure.Files.CSV.Errors; + +internal class AttributeParsingError(string attributeName) : IError +{ + public string Message { get; } = $"Failed to parse attribute {attributeName}"; + public Dictionary Metadata { get; } = new(); + public List Reasons { get; } = new(); +} + diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/ExceptionalError.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/ExceptionalError.cs new file mode 100644 index 00000000..96c9d320 --- /dev/null +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/ExceptionalError.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace NVs.Budget.Infrastructure.Files.CSV.Errors; + +internal class ConversionError(Exception exception) : IError +{ + public string Message => exception.Message; + public Dictionary Metadata { get; } = new() { { nameof(exception), exception } }; + public List Reasons { get; } = new(); +} + diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/NoFieldOptionsProvidedFor.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/NoFieldOptionsProvidedFor.cs new file mode 100644 index 00000000..0c50d5c2 --- /dev/null +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/NoFieldOptionsProvidedFor.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace NVs.Budget.Infrastructure.Files.CSV.Errors; + +internal class NoFieldOptionsProvidedFor(string name) : IError +{ + public string Message { get; } = $"No field options provided for {name}"; + public Dictionary Metadata { get; } = new(); + public List Reasons { get; } = new(); +} + diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/RowNotParsedError.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/RowNotParsedError.cs new file mode 100644 index 00000000..34e67c3e --- /dev/null +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/RowNotParsedError.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace NVs.Budget.Infrastructure.Files.CSV.Errors; + +internal class RowNotParsedError(int row, List reasons) : IError +{ + public string Message => "Unable to parse row!"; + public Dictionary Metadata { get; } = new() { { nameof(row), row } }; + public List Reasons { get; } = reasons.ToList(); +} + diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/ValidationFailedError.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/ValidationFailedError.cs new file mode 100644 index 00000000..01b1f15f --- /dev/null +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/Errors/ValidationFailedError.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace NVs.Budget.Infrastructure.Files.CSV.Errors; + +internal class ValidationFailedError(string errorMessage) : IError +{ + public string Message { get; } = errorMessage; + public Dictionary Metadata { get; } = new(); + public List Reasons { get; } = new(); +} + diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/NVs.Budget.Infrastructure.Files.CSV.csproj b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/NVs.Budget.Infrastructure.Files.CSV.csproj index 96f7edf0..ca30552d 100644 --- a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/NVs.Budget.Infrastructure.Files.CSV.csproj +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/NVs.Budget.Infrastructure.Files.CSV.csproj @@ -7,6 +7,7 @@ + all From dd330b9102b57640f908afd099b3cebd8267247a Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Thu, 16 Oct 2025 13:00:42 +0300 Subject: [PATCH 085/156] refactor: Move CSV parsing from frontend to backend Backend: - Update import endpoint to accept IFormFile (multipart/form-data) - Use ICsvFileReader with FileReadingSetting for proper CSV parsing - Get reading settings from IReadingSettingsRepository - Support file pattern matching for reading settings - Collect and return parsing errors in import result - Remove UnregisteredOperationRequest parsing from controller Frontend: - Update importOperations to send File via FormData - Remove client-side CSV parsing logic - Add filePattern input field for reading settings selection - Display selected filename instead of 'File loaded' - Remove unused ImportOperationsRequest and UnregisteredOperationRequest models - Simplify error handling and display Benefits: - Centralized CSV parsing logic in backend - Leverage existing reading settings infrastructure - Support complex field mappings, validation rules, and attributes - Better error handling and reporting - Consistent parsing across all import methods --- .../Controllers/OperationsController.cs | 109 +++++++++++++----- .../Models/OperationResponse.cs | 6 - .../budget-client/src/app/budget/models.ts | 13 --- .../import-operations.component.html | 25 +++- .../import-operations.component.ts | 106 ++++------------- .../app/operations/operations-api.service.ts | 26 ++++- 6 files changed, 143 insertions(+), 142 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index 8bb04696..8b96bc22 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -1,9 +1,11 @@ using System.Linq.Expressions; using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; using Asp.Versioning; using FluentResults; using MediatR; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Application.Contracts.Options; @@ -13,6 +15,7 @@ using NVs.Budget.Controllers.Web.Exceptions; using NVs.Budget.Controllers.Web.Models; using NVs.Budget.Controllers.Web.Utils; +using NVs.Budget.Infrastructure.Files.CSV.Contracts; using NVs.Budget.Utilities.Expressions; namespace NVs.Budget.Controllers.Web.Controllers; @@ -24,7 +27,9 @@ namespace NVs.Budget.Controllers.Web.Controllers; public class OperationsController( IMediator mediator, OperationMapper mapper, - ReadableExpressionsParser parser) : Controller + ReadableExpressionsParser parser, + ICsvFileReader csvReader, + IReadingSettingsRepository settingsRepository) : Controller { /// /// Gets all operations for a specific budget @@ -92,71 +97,112 @@ public async IAsyncEnumerable GetOperations( } /// - /// Imports new operations into a budget + /// Imports new operations into a budget from CSV file /// /// Budget ID from route - /// Import operations request + /// CSV file to import + /// Budget version for optimistic concurrency + /// Optional transfer detection confidence level + /// Optional file pattern to match reading settings (default: .*) /// Cancellation token /// Import result with success/failure details [HttpPost("import")] - [Consumes("application/json", "application/yaml", "text/yaml")] + [Consumes("multipart/form-data")] [ProducesResponseType(typeof(ImportResultResponse), 200)] [ProducesResponseType(typeof(IEnumerable), 400)] [ProducesResponseType(typeof(IEnumerable), 404)] public async Task ImportOperations( [FromRoute] Guid budgetId, - [FromBody] ImportOperationsRequest request, - CancellationToken ct) + [FromForm] IFormFile file, + [FromForm] string budgetVersion, + [FromForm] string? transferConfidenceLevel = null, + [FromForm] string? filePattern = null, + CancellationToken ct = default) { + // Validate file + if (file == null || file.Length == 0) + { + return BadRequest(new List { new("No file uploaded") }); + } + + if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new List { new("Only CSV files are supported") }); + } + // Validate budget access var budgets = await mediator.Send(new ListOwnedBudgetsQuery(), ct); var budget = budgets.FirstOrDefault(b => b.Id == budgetId); if (budget == null) { - return NotFound(new List { new($"Budget with ID {budgetId} not found or access denied") }); + throw new NotFoundException($"Budget with ID {budgetId} not found or access denied"); } - // Parse operations - var operations = new List(); - foreach (var op in request.Operations) + // Get reading settings for this budget + var allSettings = await settingsRepository.GetReadingSettingsFor(budget, ct); + + // Find matching setting by file pattern + var pattern = filePattern ?? ".*"; + var regex = new Regex(pattern); + FileReadingSetting? readingSetting = allSettings + .Where(kvp => kvp.Key.IsMatch(file.FileName)) + .Select(kvp => kvp.Value) + .FirstOrDefault(); + + if (readingSetting == null) { - var parseResult = mapper.FromRequest(op); - if (parseResult.IsFailed) - { - return BadRequest(parseResult.Errors); - } - operations.Add(parseResult.Value); + // Try to match the provided pattern + readingSetting = allSettings + .Where(kvp => kvp.Key.ToString() == pattern) + .Select(kvp => kvp.Value) + .FirstOrDefault(); + } + + if (readingSetting == null) + { + return BadRequest(new List { new($"No reading settings found for file pattern '{pattern}'. Please configure reading settings for this budget first.") }); } // Parse transfer confidence level - DetectionAccuracy? transferConfidenceLevel = null; - if (!string.IsNullOrWhiteSpace(request.TransferConfidenceLevel)) + DetectionAccuracy? transferAccuracy = null; + if (!string.IsNullOrWhiteSpace(transferConfidenceLevel)) { - var accuracyResult = mapper.ParseDetectionAccuracy(request.TransferConfidenceLevel); + var accuracyResult = mapper.ParseDetectionAccuracy(transferConfidenceLevel); if (accuracyResult.IsFailed) { return BadRequest(accuracyResult.Errors); } - transferConfidenceLevel = accuracyResult.Value; + transferAccuracy = accuracyResult.Value; } // Update budget version for optimistic concurrency - budget.Version = request.BudgetVersion; + budget.Version = budgetVersion; - var options = new ImportOptions(transferConfidenceLevel); - - async IAsyncEnumerable GetOperationsAsync() + // Read and parse CSV file + var parseErrors = new List(); + async IAsyncEnumerable ReadOperationsAsync() { - foreach (var op in operations) + using var stream = file.OpenReadStream(); + using var reader = new StreamReader(stream, readingSetting.Encoding); + + await foreach (var result in csvReader.ReadUntrackedOperations(reader, readingSetting, ct)) { - yield return op; + if (result.IsSuccess) + { + yield return result.Value; + } + else + { + // Collect parsing errors + parseErrors.AddRange(result.Errors.Select(e => e.Message)); + } } - await Task.CompletedTask; } - + + var options = new ImportOptions(transferAccuracy); var command = new ImportOperationsCommand( - GetOperationsAsync(), + ReadOperationsAsync(), budget, options ); @@ -165,10 +211,13 @@ async IAsyncEnumerable GetOperationsAsync() if (result.IsSuccess) { + // Combine parsing errors with import reasons + var allErrors = parseErrors.Concat(result.Reasons.Select(e => e.Message)).ToList(); + var response = new ImportResultResponse( result.Operations.Select(mapper.ToResponse).ToList(), result.Duplicates.Select(group => group.Select(mapper.ToResponse).ToList()).ToList(), - result.Reasons.Select(e => e.Message).ToList() + allErrors ); return Ok(response); } diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs index 23e7bf71..9b0e2c0e 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs @@ -35,12 +35,6 @@ public record UpdateOperationRequest( Dictionary? Attributes ); -public record ImportOperationsRequest( - string BudgetVersion, - IReadOnlyCollection Operations, - string? TransferConfidenceLevel -); - public record UpdateOperationsRequest( string BudgetVersion, IReadOnlyCollection Operations, diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts index cf84598a..781798ee 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts @@ -102,13 +102,6 @@ export interface OperationResponse { attributes?: Record; } -export interface UnregisteredOperationRequest { - timestamp: string; - amount: MoneyResponse; - description: string; - attributes?: Record; -} - export interface UpdateOperationRequest { id: string; version: string; @@ -119,12 +112,6 @@ export interface UpdateOperationRequest { attributes?: Record; } -export interface ImportOperationsRequest { - budgetVersion: string; - operations: UnregisteredOperationRequest[]; - transferConfidenceLevel?: string; -} - export interface UpdateOperationsRequest { budgetVersion: string; operations: UpdateOperationRequest[]; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html index 1e578909..868f6bf1 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html @@ -13,8 +13,8 @@

Import Operations

Upload CSV File

- CSV file should have columns: timestamp, amount, currency, description. - Any additional columns will be stored as attributes. + The CSV file will be parsed using the reading settings configured for this budget. + Make sure you have configured the reading settings before importing.

@@ -34,12 +34,27 @@

Upload CSV File

Choose CSV File - @if (csvContent) { - ✓ File loaded + @if (selectedFile) { + ✓ {{ selectedFile.name }} }
+
+ + + + +

+ Regex pattern to match reading settings. Leave empty to auto-match by filename. +

+
+
@@ -61,7 +76,7 @@

Upload CSV File

type="button" appearance="primary" size="m" - [disabled]="!csvContent || isLoading" + [disabled]="!selectedFile || isLoading" (click)="importCsv()"> Import Operations diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts index 9c36f425..277bc6c8 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts @@ -1,11 +1,10 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { switchMap, catchError, of } from 'rxjs'; import { OperationsApiService } from '../operations-api.service'; import { BudgetApiService } from '../../budget/budget-api.service'; -import { BudgetResponse, UnregisteredOperationRequest, ImportOperationsRequest } from '../../budget/models'; +import { BudgetResponse } from '../../budget/models'; import { TuiButton, TuiDialogService, @@ -38,7 +37,7 @@ export class ImportOperationsComponent implements OnInit { isLoading = false; importForm!: FormGroup; - csvContent: string = ''; + selectedFile: File | null = null; importResult: { registered: number; duplicates: number; errors: string[] } | null = null; constructor( @@ -54,7 +53,8 @@ export class ImportOperationsComponent implements OnInit { this.budgetId = this.route.snapshot.params['budgetId']; this.importForm = this.fb.group({ - transferConfidenceLevel: [''] + transferConfidenceLevel: [''], + filePattern: [''] }); this.loadBudget(); @@ -76,24 +76,16 @@ export class ImportOperationsComponent implements OnInit { onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; - if (!input.files || input.files.length === 0) return; + if (!input.files || input.files.length === 0) { + this.selectedFile = null; + return; + } - const file = input.files[0]; - const reader = new FileReader(); - - reader.onload = (e) => { - this.csvContent = e.target?.result as string; - }; - - reader.onerror = () => { - this.showError('Failed to read file'); - }; - - reader.readAsText(file); + this.selectedFile = input.files[0]; } importCsv(): void { - if (!this.csvContent || !this.budget) { + if (!this.selectedFile || !this.budget) { this.showError('Please select a CSV file first'); return; } @@ -101,21 +93,16 @@ export class ImportOperationsComponent implements OnInit { this.isLoading = true; this.importResult = null; - // Parse CSV content - const operations = this.parseCsv(this.csvContent); - if (operations.length === 0) { - this.isLoading = false; - this.showError('No valid operations found in CSV'); - return; - } - - const request: ImportOperationsRequest = { - budgetVersion: this.budget.version, - operations: operations, - transferConfidenceLevel: this.importForm.value.transferConfidenceLevel || undefined - }; + const transferConfidenceLevel = this.importForm.value.transferConfidenceLevel || undefined; + const filePattern = this.importForm.value.filePattern || undefined; - this.operationsApi.importOperations(this.budgetId, request).subscribe({ + this.operationsApi.importOperations( + this.budgetId, + this.selectedFile, + this.budget.version, + transferConfidenceLevel, + filePattern + ).subscribe({ next: (result) => { this.isLoading = false; this.importResult = { @@ -128,7 +115,10 @@ export class ImportOperationsComponent implements OnInit { this.showSuccess(`Successfully imported ${result.registeredOperations.length} operations`); this.operationsApi.triggerRefresh(this.budgetId); } else { - this.showError(`Import completed with errors: ${result.errors.join('; ')}`); + const errorMessage = result.errors.length > 5 + ? `Import completed with ${result.errors.length} errors. Check the results below.` + : `Import completed with errors: ${result.errors.slice(0, 3).join('; ')}`; + this.showError(errorMessage); } }, error: (error) => { @@ -138,54 +128,6 @@ export class ImportOperationsComponent implements OnInit { }); } - parseCsv(content: string): UnregisteredOperationRequest[] { - const lines = content.split('\n').filter(line => line.trim()); - if (lines.length < 2) return []; // Need header + at least one row - - const header = lines[0].split(',').map(h => h.trim()); - const operations: UnregisteredOperationRequest[] = []; - - // Find column indices - const timestampIdx = header.findIndex(h => h.toLowerCase() === 'timestamp' || h.toLowerCase() === 'date'); - const amountIdx = header.findIndex(h => h.toLowerCase() === 'amount'); - const currencyIdx = header.findIndex(h => h.toLowerCase() === 'currency'); - const descriptionIdx = header.findIndex(h => h.toLowerCase() === 'description'); - - if (timestampIdx === -1 || amountIdx === -1 || currencyIdx === -1 || descriptionIdx === -1) { - this.showError('CSV must have columns: timestamp, amount, currency, description'); - return []; - } - - for (let i = 1; i < lines.length; i++) { - const values = lines[i].split(',').map(v => v.trim()); - - try { - const operation: UnregisteredOperationRequest = { - timestamp: new Date(values[timestampIdx]).toISOString(), - amount: { - value: parseFloat(values[amountIdx]), - currencyCode: values[currencyIdx] - }, - description: values[descriptionIdx], - attributes: {} - }; - - // Add remaining columns as attributes - for (let j = 0; j < header.length; j++) { - if (j !== timestampIdx && j !== amountIdx && j !== currencyIdx && j !== descriptionIdx) { - operation.attributes![header[j]] = values[j]; - } - } - - operations.push(operation); - } catch (error) { - console.warn(`Skipping invalid row ${i + 1}:`, error); - } - } - - return operations; - } - viewOperations(): void { this.router.navigate(['/budget', this.budgetId, 'operations']); } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts index 25307d7c..07fc64b4 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts @@ -3,7 +3,6 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { BehaviorSubject, Observable, startWith, switchMap } from 'rxjs'; import { OperationResponse, - ImportOperationsRequest, UpdateOperationsRequest, RemoveOperationsRequest, ImportResultResponse, @@ -56,14 +55,29 @@ export class OperationsApiService { } /** - * Import operations into a budget + * Import operations into a budget from CSV file */ - importOperations(budgetId: string, request: ImportOperationsRequest): Observable { - const headers = new HttpHeaders().set('Content-Type', 'application/json'); + importOperations( + budgetId: string, + file: File, + budgetVersion: string, + transferConfidenceLevel?: string, + filePattern?: string + ): Observable { + const formData = new FormData(); + formData.append('file', file); + formData.append('budgetVersion', budgetVersion); + if (transferConfidenceLevel) { + formData.append('transferConfidenceLevel', transferConfidenceLevel); + } + if (filePattern) { + formData.append('filePattern', filePattern); + } + return this.http.post( `${this.baseUrl}/budget/${budgetId}/operations/import`, - request, - { headers, withCredentials: true } + formData, + { withCredentials: true } ); } From bc3e5522ca78c2d9e588668d58ed7513d54ca284 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Thu, 16 Oct 2025 20:33:09 +0300 Subject: [PATCH 086/156] fix: File upload button not opening file dialog - Remove label wrapper approach which was preventing click events - Use template reference variable and programmatic click - Add click handler to button for direct file input trigger - Remove unused file-label CSS class - This is a more reliable pattern that works with Angular and Taiga UI --- .../import-operations.component.html | 19 +++++++++---------- .../import-operations.component.less | 4 ---- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html index 868f6bf1..2006ead0 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html @@ -23,17 +23,16 @@

Upload CSV File

accept=".csv" (change)="onFileSelected($event)" class="file-input" - id="csvFile" + #fileInput /> - + @if (selectedFile) { ✓ {{ selectedFile.name }} } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less index 5fe145c7..6219ba21 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less @@ -53,10 +53,6 @@ display: none; } - .file-label { - cursor: pointer; - } - .file-selected { color: var(--tui-success-fill); font-weight: 600; From 9dda16023fa6bb0f15596147660f603c450a5859 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Thu, 16 Oct 2025 21:38:00 +0300 Subject: [PATCH 087/156] fix: bumped coverlet version --- .../NVs.Budget.Application.Tests.csproj | 2 +- .../NVs.Budget.Controllers.Console.Handlers.Tests.csproj | 2 +- .../NVs.Budget.Controllers.Web.Tests.csproj | 2 +- .../NVs.Budget.Domain.Tests/NVs.Budget.Domain.Tests.csproj | 2 +- .../NVs.Budget.Infrastructure.IO.Console.Tests.csproj | 2 +- .../NVs.Budget.Infrastructure.Files.CSV.Tests.csproj | 2 +- .../NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj | 2 +- .../NVs.Budget.Utilities.Expressions.Tests.csproj | 2 +- .../NVs.Budget.Utilities.Json.Tests.csproj | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Application/NVs.Budget.Application.Tests/NVs.Budget.Application.Tests.csproj b/src/Application/NVs.Budget.Application.Tests/NVs.Budget.Application.Tests.csproj index 3013a850..19e27640 100644 --- a/src/Application/NVs.Budget.Application.Tests/NVs.Budget.Application.Tests.csproj +++ b/src/Application/NVs.Budget.Application.Tests/NVs.Budget.Application.Tests.csproj @@ -20,7 +20,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Controllers/NVs.Budget.Controllers.Console.Handlers.Tests/NVs.Budget.Controllers.Console.Handlers.Tests.csproj b/src/Controllers/NVs.Budget.Controllers.Console.Handlers.Tests/NVs.Budget.Controllers.Console.Handlers.Tests.csproj index ef1c5077..8c892ec0 100644 --- a/src/Controllers/NVs.Budget.Controllers.Console.Handlers.Tests/NVs.Budget.Controllers.Console.Handlers.Tests.csproj +++ b/src/Controllers/NVs.Budget.Controllers.Console.Handlers.Tests/NVs.Budget.Controllers.Console.Handlers.Tests.csproj @@ -19,7 +19,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Controllers/NVs.Budget.Controllers.Web.Tests/NVs.Budget.Controllers.Web.Tests.csproj b/src/Controllers/NVs.Budget.Controllers.Web.Tests/NVs.Budget.Controllers.Web.Tests.csproj index 2ed2ae36..30995166 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web.Tests/NVs.Budget.Controllers.Web.Tests.csproj +++ b/src/Controllers/NVs.Budget.Controllers.Web.Tests/NVs.Budget.Controllers.Web.Tests.csproj @@ -19,7 +19,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Domain/NVs.Budget.Domain.Tests/NVs.Budget.Domain.Tests.csproj b/src/Domain/NVs.Budget.Domain.Tests/NVs.Budget.Domain.Tests.csproj index c63d45ea..ea5150ac 100644 --- a/src/Domain/NVs.Budget.Domain.Tests/NVs.Budget.Domain.Tests.csproj +++ b/src/Domain/NVs.Budget.Domain.Tests/NVs.Budget.Domain.Tests.csproj @@ -18,7 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console.Tests/NVs.Budget.Infrastructure.IO.Console.Tests.csproj b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console.Tests/NVs.Budget.Infrastructure.IO.Console.Tests.csproj index fb0d8280..6b585847 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console.Tests/NVs.Budget.Infrastructure.IO.Console.Tests.csproj +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console.Tests/NVs.Budget.Infrastructure.IO.Console.Tests.csproj @@ -25,7 +25,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/NVs.Budget.Infrastructure.Files.CSV.Tests.csproj b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/NVs.Budget.Infrastructure.Files.CSV.Tests.csproj index 51b2e286..241269ed 100644 --- a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/NVs.Budget.Infrastructure.Files.CSV.Tests.csproj +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/NVs.Budget.Infrastructure.Files.CSV.Tests.csproj @@ -22,7 +22,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Tests/NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Tests/NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj index b4b7749d..8d69bb0a 100644 --- a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Tests/NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Tests/NVs.Budget.Infrastructure.Persistence.EF.Tests.csproj @@ -20,7 +20,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Utilities/NVs.Budget.Utilities.Expressions.Tests/NVs.Budget.Utilities.Expressions.Tests.csproj b/src/Utilities/NVs.Budget.Utilities.Expressions.Tests/NVs.Budget.Utilities.Expressions.Tests.csproj index dad97fbf..2e8f353a 100644 --- a/src/Utilities/NVs.Budget.Utilities.Expressions.Tests/NVs.Budget.Utilities.Expressions.Tests.csproj +++ b/src/Utilities/NVs.Budget.Utilities.Expressions.Tests/NVs.Budget.Utilities.Expressions.Tests.csproj @@ -17,7 +17,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Utilities/NVs.Budget.Utilities.Json.Tests/NVs.Budget.Utilities.Json.Tests.csproj b/src/Utilities/NVs.Budget.Utilities.Json.Tests/NVs.Budget.Utilities.Json.Tests.csproj index 63e6a181..c08127a9 100644 --- a/src/Utilities/NVs.Budget.Utilities.Json.Tests/NVs.Budget.Utilities.Json.Tests.csproj +++ b/src/Utilities/NVs.Budget.Utilities.Json.Tests/NVs.Budget.Utilities.Json.Tests.csproj @@ -18,7 +18,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 6da7fadbff12030ea6ffe1f1e46531d6bec202c1 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Thu, 16 Oct 2025 22:00:58 +0300 Subject: [PATCH 088/156] fixed currency mapping issue --- .../CsvFileReaderShould.cs | 44 +++++++++---------- .../CsvFileReader.cs | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/CsvFileReaderShould.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/CsvFileReaderShould.cs index d8fb0922..50fe5b9e 100644 --- a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/CsvFileReaderShould.cs +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV.Tests/CsvFileReaderShould.cs @@ -45,7 +45,7 @@ public async Task ReadSimpleOperationsSuccessfully() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -86,7 +86,7 @@ public async Task ReadOperationsWithPatternCombinations() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}.{2}", - ["Amount.CurrencyCode"] = "{3}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{3}", [nameof(UnregisteredOperation.Description)] = "{4} {5}" }, Attributes: new Dictionary(), @@ -125,7 +125,7 @@ public async Task ReadOperationsWithAttributes() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary @@ -171,7 +171,7 @@ public async Task SkipRowsThatFailValidation_Equals() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -212,7 +212,7 @@ public async Task ReturnFailureForRowsWithValidationError() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -255,7 +255,7 @@ public async Task ReturnFailureForInvalidDateFormat() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -288,7 +288,7 @@ public async Task ReturnFailureForInvalidAmountFormat() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -321,7 +321,7 @@ public async Task ReturnFailureForInvalidCurrencyCode() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -354,7 +354,7 @@ public async Task ReturnFailureForMissingRequiredField() { // Missing Timestamp field [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -389,7 +389,7 @@ public async Task HandleEmptyRows() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -421,7 +421,7 @@ public async Task ReadOperationsWithLocalDateTimeKind() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -457,7 +457,7 @@ public async Task HandleComplexValidationRules() { [nameof(UnregisteredOperation.Timestamp)] = "{1}", [nameof(UnregisteredOperation.Amount)] = "{2}", - ["Amount.CurrencyCode"] = "{3}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{3}", [nameof(UnregisteredOperation.Description)] = "{4}" }, Attributes: new Dictionary @@ -504,7 +504,7 @@ public async Task IncludeRowNumberInErrorMetadata() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -540,7 +540,7 @@ public async Task HandlePatternWithoutPlaceholders() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "Default description" // No placeholder }, Attributes: new Dictionary(), @@ -574,7 +574,7 @@ public async Task ReadOperationsWithFrenchCulture() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -612,7 +612,7 @@ public async Task ReadOperationsWithRussianCulture() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -650,7 +650,7 @@ public async Task ReadOperationsWithJapaneseCulture() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -688,7 +688,7 @@ public async Task ReadOperationsWithUTF16Encoding() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -721,7 +721,7 @@ public async Task ReadOperationsWithUTF32Encoding() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -757,7 +757,7 @@ public async Task ReadOperationsWithLatin1Encoding() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -792,7 +792,7 @@ public async Task ReadOperationsWithChineseCulture() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), @@ -830,7 +830,7 @@ public async Task ReadOperationsWithArabicCulture() { [nameof(UnregisteredOperation.Timestamp)] = "{0}", [nameof(UnregisteredOperation.Amount)] = "{1}", - ["Amount.CurrencyCode"] = "{2}", + [nameof(UnregisteredOperation.Amount.CurrencyCode)] = "{2}", [nameof(UnregisteredOperation.Description)] = "{3}" }, Attributes: new Dictionary(), diff --git a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvFileReader.cs b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvFileReader.cs index a74f8e46..77e10d81 100644 --- a/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvFileReader.cs +++ b/src/Infrastructure/Files/NVs.Budget.Infrastructure.Files.CSV/CsvFileReader.cs @@ -108,7 +108,7 @@ private Result ParseRow(IParser parser, FileReadingSettin } // Parse currency code - var currencyResult = ParseField(parser, config.Fields, "Amount.CurrencyCode", Currency.Get); + var currencyResult = ParseField(parser, config.Fields, nameof(UnregisteredOperation.Amount.CurrencyCode), Currency.Get); if (currencyResult.IsFailed) { return BuildParseError(rowNumber, currencyResult.Errors); From ff1fd079793b48792bdead35414c521eca1c3090 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Fri, 17 Oct 2025 08:33:03 +0300 Subject: [PATCH 089/156] feat: detailed info about import and updates --- .../Controllers/OperationsController.cs | 12 +++++++----- .../Models/OperationResponse.cs | 9 +++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index 8b96bc22..d79ff509 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -180,7 +180,7 @@ public async Task ImportOperations( budget.Version = budgetVersion; // Read and parse CSV file - var parseErrors = new List(); + var parseErrors = new List(); async IAsyncEnumerable ReadOperationsAsync() { using var stream = file.OpenReadStream(); @@ -195,7 +195,7 @@ async IAsyncEnumerable ReadOperationsAsync() else { // Collect parsing errors - parseErrors.AddRange(result.Errors.Select(e => e.Message)); + parseErrors.AddRange(result.Errors); } } } @@ -212,12 +212,13 @@ async IAsyncEnumerable ReadOperationsAsync() if (result.IsSuccess) { // Combine parsing errors with import reasons - var allErrors = parseErrors.Concat(result.Reasons.Select(e => e.Message)).ToList(); + var allErrors = parseErrors.Concat(result.Errors).ToList(); var response = new ImportResultResponse( result.Operations.Select(mapper.ToResponse).ToList(), result.Duplicates.Select(group => group.Select(mapper.ToResponse).ToList()).ToList(), - allErrors + allErrors, + result.Successes ); return Ok(response); } @@ -307,7 +308,8 @@ async IAsyncEnumerable GetOperationsAsync() { var response = new UpdateResultResponse( result.Operations.Select(mapper.ToResponse).ToList(), - result.Reasons.Select(e => e.Message).ToList() + result.Errors, + result.Successes ); return Ok(response); } diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs index 9b0e2c0e..6383d2a3 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs @@ -1,4 +1,4 @@ -using NVs.Budget.Application.Contracts.Options; +using FluentResults; namespace NVs.Budget.Controllers.Web.Models; @@ -50,11 +50,12 @@ string Criteria public record ImportResultResponse( IReadOnlyCollection RegisteredOperations, IReadOnlyCollection> Duplicates, - IReadOnlyCollection Errors -); + IReadOnlyCollection Errors, + IReadOnlyCollection Successes); public record UpdateResultResponse( IReadOnlyCollection UpdatedOperations, - IReadOnlyCollection Errors + IReadOnlyCollection Errors, + IReadOnlyCollection Successes ); From cc1d6a314918e4ad17221c4863582bb88a0b55e0 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Fri, 17 Oct 2025 08:33:16 +0300 Subject: [PATCH 090/156] chore: removed unused usings --- .../UseCases/Budgets/ChangeBudgetOwnersCommand.cs | 1 - .../UseCases/Operations/UpdateOperationsCommand.cs | 1 - .../UseCases/Transfers/SearchTransfersCommand.cs | 2 -- .../NVs.Budget.Application.Tests/AccountantShould.cs | 1 - .../NVs.Budget.Application.Tests/ReckonerShould.cs | 4 +--- .../Operations/UpdateOperationsCommandHandler.cs | 1 - .../Services/Accounting/Results/ImportResultBuilder.cs | 5 +---- .../Services/Accounting/Results/UpdateResultBuilder.cs | 1 - .../Services/Accounting/Tags/TagsManager.cs | 1 - .../Services/Accounting/Transfers/TransferDetector.cs | 4 +--- .../Commands/Transfers/SearchVerb.cs | 1 - .../NVs.Budget.Controllers.Web.Tests/BudgetMapperShould.cs | 2 -- .../Controllers/BudgetController.cs | 1 - .../NVs.Budget.Controllers.Web/Filters/EnumSchemaFilter.cs | 1 - .../Formatters/YamlOutputFormatter.cs | 1 - .../Middleware/ExceptionHandlingMiddleware.cs | 1 - .../NVs.Budget.Controllers.Web/WebControllersExtensions.cs | 1 - .../Commands/ListEffectiveSettingsVerb.cs | 1 - src/Hosts/NVs.Budget.Hosts.Console/Program.cs | 3 --- .../YamlBasedTransferCriteriaReaderShould.cs | 2 -- .../Input/YamlBasedCsvReadingOptionsReader.cs | 1 - .../Input/YamlBasedTaggingCriteriaReader.cs | 1 - .../Input/YamlBasedTransferCriteriaReader.cs | 1 - .../Options/OutputOptionsChanger.cs | 1 - .../Output/Budgets/TrackedBudgetWriter.cs | 2 -- .../Output/Logbook/LogbookOperationsWriter.cs | 1 - .../Output/YamlBasedTransferCriteriaWriter.cs | 1 - .../Oauth2BasedIdentityService.cs | 2 -- .../IDbConnectionInfo.cs | 2 -- .../IDbMigrator.cs | 2 -- .../EfCorePersistenceExtensions.cs | 1 - 31 files changed, 3 insertions(+), 47 deletions(-) diff --git a/src/Application/NVs.Budget.Application.Contracts/UseCases/Budgets/ChangeBudgetOwnersCommand.cs b/src/Application/NVs.Budget.Application.Contracts/UseCases/Budgets/ChangeBudgetOwnersCommand.cs index 966dbb1d..93c4e6ce 100644 --- a/src/Application/NVs.Budget.Application.Contracts/UseCases/Budgets/ChangeBudgetOwnersCommand.cs +++ b/src/Application/NVs.Budget.Application.Contracts/UseCases/Budgets/ChangeBudgetOwnersCommand.cs @@ -1,6 +1,5 @@ using FluentResults; using MediatR; -using NVs.Budget.Application.Contracts.Entities; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Domain.Entities.Budgets; diff --git a/src/Application/NVs.Budget.Application.Contracts/UseCases/Operations/UpdateOperationsCommand.cs b/src/Application/NVs.Budget.Application.Contracts/UseCases/Operations/UpdateOperationsCommand.cs index bf20312b..d7de5a11 100644 --- a/src/Application/NVs.Budget.Application.Contracts/UseCases/Operations/UpdateOperationsCommand.cs +++ b/src/Application/NVs.Budget.Application.Contracts/UseCases/Operations/UpdateOperationsCommand.cs @@ -1,4 +1,3 @@ -using FluentResults; using MediatR; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Application.Contracts.Options; diff --git a/src/Application/NVs.Budget.Application.Contracts/UseCases/Transfers/SearchTransfersCommand.cs b/src/Application/NVs.Budget.Application.Contracts/UseCases/Transfers/SearchTransfersCommand.cs index eaac072f..e1a70292 100644 --- a/src/Application/NVs.Budget.Application.Contracts/UseCases/Transfers/SearchTransfersCommand.cs +++ b/src/Application/NVs.Budget.Application.Contracts/UseCases/Transfers/SearchTransfersCommand.cs @@ -1,8 +1,6 @@ using System.Linq.Expressions; -using FluentResults; using MediatR; using NVs.Budget.Application.Contracts.Entities.Budgeting; -using NVs.Budget.Domain.Entities.Transactions; namespace NVs.Budget.Application.Contracts.UseCases.Transfers; diff --git a/src/Application/NVs.Budget.Application.Tests/AccountantShould.cs b/src/Application/NVs.Budget.Application.Tests/AccountantShould.cs index 911b8f08..fd92dfb1 100644 --- a/src/Application/NVs.Budget.Application.Tests/AccountantShould.cs +++ b/src/Application/NVs.Budget.Application.Tests/AccountantShould.cs @@ -6,7 +6,6 @@ using NVs.Budget.Application.Contracts.Options; using NVs.Budget.Application.Services.Accounting; using NVs.Budget.Application.Services.Accounting.Duplicates; -using NVs.Budget.Application.Services.Accounting.Results; using NVs.Budget.Application.Tests.Fakes; using NVs.Budget.Domain.Entities.Budgets; using NVs.Budget.Utilities.Expressions; diff --git a/src/Application/NVs.Budget.Application.Tests/ReckonerShould.cs b/src/Application/NVs.Budget.Application.Tests/ReckonerShould.cs index 429613fa..70e6da70 100644 --- a/src/Application/NVs.Budget.Application.Tests/ReckonerShould.cs +++ b/src/Application/NVs.Budget.Application.Tests/ReckonerShould.cs @@ -1,6 +1,4 @@ -using System.Collections; -using System.Collections.ObjectModel; -using AutoFixture; +using AutoFixture; using FluentAssertions; using FluentAssertions.Execution; using Moq; diff --git a/src/Application/NVs.Budget.Application.UseCases/Operations/UpdateOperationsCommandHandler.cs b/src/Application/NVs.Budget.Application.UseCases/Operations/UpdateOperationsCommandHandler.cs index aa869e61..d984bce0 100644 --- a/src/Application/NVs.Budget.Application.UseCases/Operations/UpdateOperationsCommandHandler.cs +++ b/src/Application/NVs.Budget.Application.UseCases/Operations/UpdateOperationsCommandHandler.cs @@ -1,4 +1,3 @@ -using FluentResults; using MediatR; using NVs.Budget.Application.Contracts.Results; using NVs.Budget.Application.Contracts.Services; diff --git a/src/Application/NVs.Budget.Application/Services/Accounting/Results/ImportResultBuilder.cs b/src/Application/NVs.Budget.Application/Services/Accounting/Results/ImportResultBuilder.cs index 3f0ba63d..871ebddb 100644 --- a/src/Application/NVs.Budget.Application/Services/Accounting/Results/ImportResultBuilder.cs +++ b/src/Application/NVs.Budget.Application/Services/Accounting/Results/ImportResultBuilder.cs @@ -1,8 +1,5 @@ -using FluentResults; -using NVs.Budget.Application.Contracts.Entities.Budgeting; -using NVs.Budget.Application.Contracts.Results; +using NVs.Budget.Application.Contracts.Results; using NVs.Budget.Application.Services.Accounting.Duplicates; -using NVs.Budget.Application.Services.Accounting.Results.Successes; namespace NVs.Budget.Application.Services.Accounting.Results; diff --git a/src/Application/NVs.Budget.Application/Services/Accounting/Results/UpdateResultBuilder.cs b/src/Application/NVs.Budget.Application/Services/Accounting/Results/UpdateResultBuilder.cs index dee7886d..764f6f0a 100644 --- a/src/Application/NVs.Budget.Application/Services/Accounting/Results/UpdateResultBuilder.cs +++ b/src/Application/NVs.Budget.Application/Services/Accounting/Results/UpdateResultBuilder.cs @@ -1,7 +1,6 @@ using FluentResults; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Application.Contracts.Results; -using NVs.Budget.Application.Services.Accounting.Duplicates; using NVs.Budget.Application.Services.Accounting.Results.Successes; namespace NVs.Budget.Application.Services.Accounting.Results; diff --git a/src/Application/NVs.Budget.Application/Services/Accounting/Tags/TagsManager.cs b/src/Application/NVs.Budget.Application/Services/Accounting/Tags/TagsManager.cs index e4157da0..229a086f 100644 --- a/src/Application/NVs.Budget.Application/Services/Accounting/Tags/TagsManager.cs +++ b/src/Application/NVs.Budget.Application/Services/Accounting/Tags/TagsManager.cs @@ -1,7 +1,6 @@ using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Domain.ValueObjects; -using NVs.Budget.Utilities.Expressions; namespace NVs.Budget.Application.Services.Accounting.Tags; diff --git a/src/Application/NVs.Budget.Application/Services/Accounting/Transfers/TransferDetector.cs b/src/Application/NVs.Budget.Application/Services/Accounting/Transfers/TransferDetector.cs index 149e25f6..42337b04 100644 --- a/src/Application/NVs.Budget.Application/Services/Accounting/Transfers/TransferDetector.cs +++ b/src/Application/NVs.Budget.Application/Services/Accounting/Transfers/TransferDetector.cs @@ -1,10 +1,8 @@ -using System.Linq.Expressions; -using FluentResults; +using FluentResults; using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Application.Services.Accounting.Results.Errors; using NVs.Budget.Domain.Extensions; -using NVs.Budget.Utilities.Expressions; namespace NVs.Budget.Application.Services.Accounting.Transfers; diff --git a/src/Controllers/NVs.Budget.Controllers.Console.Handlers/Commands/Transfers/SearchVerb.cs b/src/Controllers/NVs.Budget.Controllers.Console.Handlers/Commands/Transfers/SearchVerb.cs index cbc1a07d..29856520 100644 --- a/src/Controllers/NVs.Budget.Controllers.Console.Handlers/Commands/Transfers/SearchVerb.cs +++ b/src/Controllers/NVs.Budget.Controllers.Console.Handlers/Commands/Transfers/SearchVerb.cs @@ -8,7 +8,6 @@ using NVs.Budget.Application.Contracts.Services; using NVs.Budget.Application.Contracts.UseCases.Transfers; using NVs.Budget.Controllers.Console.Contracts.Commands; -using NVs.Budget.Domain.Entities.Transactions; using NVs.Budget.Infrastructure.IO.Console.Input.Criteria; using NVs.Budget.Infrastructure.IO.Console.Output; using Error = FluentResults.Error; diff --git a/src/Controllers/NVs.Budget.Controllers.Web.Tests/BudgetMapperShould.cs b/src/Controllers/NVs.Budget.Controllers.Web.Tests/BudgetMapperShould.cs index bf7ced19..811e715c 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web.Tests/BudgetMapperShould.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web.Tests/BudgetMapperShould.cs @@ -6,9 +6,7 @@ using NVs.Budget.Controllers.Web.Utils; using NVs.Budget.Domain.Entities.Budgets; using NVs.Budget.Domain.Entities.Operations; -using NVs.Budget.Domain.ValueObjects; using NVs.Budget.Utilities.Expressions; -using NVs.Budget.Utilities.Testing; namespace NVs.Budget.Controllers.Web.Tests; diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs index 77fda1d9..99f94506 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/BudgetController.cs @@ -9,7 +9,6 @@ using NVs.Budget.Application.Contracts.UseCases.Owners; using NVs.Budget.Controllers.Web.Models; using NVs.Budget.Controllers.Web.Utils; -using NVs.Budget.Domain.Entities.Budgets; using NVs.Budget.Infrastructure.Files.CSV.Contracts; namespace NVs.Budget.Controllers.Web.Controllers; diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Filters/EnumSchemaFilter.cs b/src/Controllers/NVs.Budget.Controllers.Web/Filters/EnumSchemaFilter.cs index 5bf3b3ef..9e8c85b5 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Filters/EnumSchemaFilter.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Filters/EnumSchemaFilter.cs @@ -1,6 +1,5 @@ using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; -using System.Text.Json.Serialization; namespace NVs.Budget.Controllers.Web.Filters; diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlOutputFormatter.cs b/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlOutputFormatter.cs index ed438fa5..1ba6a49e 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlOutputFormatter.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Formatters/YamlOutputFormatter.cs @@ -1,4 +1,3 @@ -using System; using System.Text; using Microsoft.AspNetCore.Mvc.Formatters; using YamlDotNet.Serialization; diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Middleware/ExceptionHandlingMiddleware.cs b/src/Controllers/NVs.Budget.Controllers.Web/Middleware/ExceptionHandlingMiddleware.cs index 168602ff..4ec4ce56 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Middleware/ExceptionHandlingMiddleware.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Middleware/ExceptionHandlingMiddleware.cs @@ -1,6 +1,5 @@ using System.Net; using System.Text.Json; -using FluentResults; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using NVs.Budget.Controllers.Web.Exceptions; diff --git a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs index e1f61ead..9ab7de06 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/WebControllersExtensions.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.OpenApi.Models; using NVs.Budget.Controllers.Web.Filters; using NVs.Budget.Controllers.Web.Formatters; diff --git a/src/Hosts/NVs.Budget.Hosts.Console/Commands/ListEffectiveSettingsVerb.cs b/src/Hosts/NVs.Budget.Hosts.Console/Commands/ListEffectiveSettingsVerb.cs index ada43491..f818cda3 100644 --- a/src/Hosts/NVs.Budget.Hosts.Console/Commands/ListEffectiveSettingsVerb.cs +++ b/src/Hosts/NVs.Budget.Hosts.Console/Commands/ListEffectiveSettingsVerb.cs @@ -6,7 +6,6 @@ using NVs.Budget.Infrastructure.IO.Console.Options; using NVs.Budget.Infrastructure.IO.Console.Output; using NVs.Budget.Infrastructure.Persistence.EF.Common; -using NVs.Budget.Infrastructure.Persistence.EF.Context; namespace NVs.Budget.Hosts.Console.Commands; diff --git a/src/Hosts/NVs.Budget.Hosts.Console/Program.cs b/src/Hosts/NVs.Budget.Hosts.Console/Program.cs index 359faa82..038232ef 100644 --- a/src/Hosts/NVs.Budget.Hosts.Console/Program.cs +++ b/src/Hosts/NVs.Budget.Hosts.Console/Program.cs @@ -3,10 +3,7 @@ using System.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using NVs.Budget.Application; -using NVs.Budget.Application.Contracts.Entities; -using NVs.Budget.Application.Contracts.Services; using NVs.Budget.Application.UseCases; using NVs.Budget.Controllers.Console.Handlers; using NVs.Budget.Domain.ValueObjects; diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console.Tests/YamlBasedTransferCriteriaReaderShould.cs b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console.Tests/YamlBasedTransferCriteriaReaderShould.cs index 3c865c0c..78d6ae35 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console.Tests/YamlBasedTransferCriteriaReaderShould.cs +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console.Tests/YamlBasedTransferCriteriaReaderShould.cs @@ -1,4 +1,3 @@ -using System.Text; using AutoFixture; using FluentAssertions; using FluentResults.Extensions.FluentAssertions; @@ -9,7 +8,6 @@ using NVs.Budget.Infrastructure.IO.Console.Options; using NVs.Budget.Infrastructure.IO.Console.Output; using NVs.Budget.Infrastructure.IO.Console.Tests.Mocks; -using NVs.Budget.Utilities.Expressions; using NVs.Budget.Utilities.Testing; namespace NVs.Budget.Infrastructure.IO.Console.Tests; diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedCsvReadingOptionsReader.cs b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedCsvReadingOptionsReader.cs index b2fbbace..07f4684c 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedCsvReadingOptionsReader.cs +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedCsvReadingOptionsReader.cs @@ -2,7 +2,6 @@ using System.Text.RegularExpressions; using FluentResults; using NVs.Budget.Domain.Extensions; -using NVs.Budget.Infrastructure.IO.Console.Input.Criteria.Logbook; using NVs.Budget.Infrastructure.IO.Console.Options; using NVs.Budget.Utilities.Yaml; using YamlDotNet.RepresentationModel; diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedTaggingCriteriaReader.cs b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedTaggingCriteriaReader.cs index 0ac54351..40b08324 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedTaggingCriteriaReader.cs +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedTaggingCriteriaReader.cs @@ -1,7 +1,6 @@ using FluentResults; using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Application.Contracts.Entities.Budgeting; -using NVs.Budget.Infrastructure.IO.Console.Input.Criteria.Logbook; using NVs.Budget.Utilities.Expressions; using NVs.Budget.Utilities.Yaml; using YamlDotNet.RepresentationModel; diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedTransferCriteriaReader.cs b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedTransferCriteriaReader.cs index f5d129e7..12baf51c 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedTransferCriteriaReader.cs +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Input/YamlBasedTransferCriteriaReader.cs @@ -2,7 +2,6 @@ using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Domain.Extensions; -using NVs.Budget.Infrastructure.IO.Console.Input.Criteria.Logbook; using NVs.Budget.Utilities.Expressions; using NVs.Budget.Utilities.Yaml; using YamlDotNet.RepresentationModel; diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Options/OutputOptionsChanger.cs b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Options/OutputOptionsChanger.cs index 47a80fbc..cc0d9b9c 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Options/OutputOptionsChanger.cs +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Options/OutputOptionsChanger.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; namespace NVs.Budget.Infrastructure.IO.Console.Options; diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/Budgets/TrackedBudgetWriter.cs b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/Budgets/TrackedBudgetWriter.cs index 13816459..622798b2 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/Budgets/TrackedBudgetWriter.cs +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/Budgets/TrackedBudgetWriter.cs @@ -1,5 +1,3 @@ -using System.Collections; -using System.Text; using AutoMapper; using CsvHelper.Configuration; using Microsoft.Extensions.Options; diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/Logbook/LogbookOperationsWriter.cs b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/Logbook/LogbookOperationsWriter.cs index ec0bfc77..8d031202 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/Logbook/LogbookOperationsWriter.cs +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/Logbook/LogbookOperationsWriter.cs @@ -1,6 +1,5 @@ using AutoMapper; using ClosedXML.Excel; -using NMoneys; using NVs.Budget.Application.Contracts.Entities.Budgeting; using NVs.Budget.Domain.Aggregates; using NVs.Budget.Domain.ValueObjects.Criteria; diff --git a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/YamlBasedTransferCriteriaWriter.cs b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/YamlBasedTransferCriteriaWriter.cs index 3506d0f7..1f3133c9 100644 --- a/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/YamlBasedTransferCriteriaWriter.cs +++ b/src/Infrastructure/Console/NVs.Budget.Infrastructure.IO.Console/Output/YamlBasedTransferCriteriaWriter.cs @@ -1,4 +1,3 @@ -using System.Collections; using Microsoft.Extensions.Options; using NVs.Budget.Application.Contracts.Criteria; using NVs.Budget.Infrastructure.IO.Console.Options; diff --git a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs index b87f7dba..65950160 100644 --- a/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs +++ b/src/Infrastructure/NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex/Oauth2BasedIdentityService.cs @@ -1,9 +1,7 @@ -using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using NVs.Budget.Application.Contracts.Entities; using NVs.Budget.Infrastructure.Identity.Contracts; using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; using NVs.Budget.Infrastructure.Persistence.Contracts.Accounting; namespace NVs.Budget.Infrastructure.Identity.OpenIddict.Yandex; diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbConnectionInfo.cs b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbConnectionInfo.cs index 75cf99cb..3145e0ca 100644 --- a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbConnectionInfo.cs +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbConnectionInfo.cs @@ -1,5 +1,3 @@ -using Microsoft.EntityFrameworkCore; - namespace NVs.Budget.Infrastructure.Persistence.EF.Common; public interface IDbConnectionInfo diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbMigrator.cs b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbMigrator.cs index c03658f5..f8922e37 100644 --- a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbMigrator.cs +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF.Common/IDbMigrator.cs @@ -1,5 +1,3 @@ -using Microsoft.EntityFrameworkCore; - namespace NVs.Budget.Infrastructure.Persistence.EF.Context; public interface IDbMigrator diff --git a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs index 84694443..e2a468c4 100644 --- a/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs +++ b/src/Infrastructure/Persistence/NVs.Budget.Infrastructure.Persistence.EF/EfCorePersistenceExtensions.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using NVs.Budget.Infrastructure.IO.Console.Options; using NVs.Budget.Infrastructure.Persistence.Contracts.Accounting; using NVs.Budget.Infrastructure.Persistence.EF.Common; using NVs.Budget.Infrastructure.Persistence.EF.Context; From a09c89e31e654cc97d239842704fc0232ccfec3e Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Fri, 17 Oct 2025 08:50:05 +0300 Subject: [PATCH 091/156] feat: Display detailed error and success metadata in import results Backend: - Update import result to separate parsing errors from import errors - Return IError and ISuccess objects with full metadata and nested reasons - Maintain backward compatibility with error collection Frontend: - Update TypeScript models to support IError and ISuccess interfaces - Remove duplicate IError interface definition - Display detailed error information with metadata and nested reasons - Add successes section to show success messages with metadata - Style error and success items with color-coded backgrounds - Show metadata details in expandable sections - Display nested error reasons ('Caused by' section) - Add stats counter for successes - Support optional message fields with fallback defaults UI Improvements: - Color-coded stat items (success, warning, error, info) - Individual cards for each error/success with border accents - Metadata displayed in key-value pairs with monospace font - Nested reasons shown as bulleted lists - Better visual hierarchy with padding and backgrounds --- .../Controllers/OperationsController.cs | 10 ++- .../budget-client/src/app/budget/models.ts | 22 +++-- .../import-operations.component.html | 61 +++++++++++-- .../import-operations.component.less | 87 ++++++++++++++++++- .../import-operations.component.ts | 19 +++- 5 files changed, 179 insertions(+), 20 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index d79ff509..c1d50d32 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -212,13 +212,19 @@ async IAsyncEnumerable ReadOperationsAsync() if (result.IsSuccess) { // Combine parsing errors with import reasons - var allErrors = parseErrors.Concat(result.Errors).ToList(); + var importErrors = result.Reasons.Where(r => r is IError).Cast().ToList(); + var allErrors = parseErrors.Concat(importErrors).ToList(); + + var allSuccesses = result.Reasons + .Where(r => r is ISuccess) + .Cast() + .ToList(); var response = new ImportResultResponse( result.Operations.Select(mapper.ToResponse).ToList(), result.Duplicates.Select(group => group.Select(mapper.ToResponse).ToList()).ToList(), allErrors, - result.Successes + allSuccesses ); return Ok(response); } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts index 781798ee..6ad969d9 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts @@ -62,12 +62,6 @@ export interface MergeBudgetsRequest { purgeEmptyBudgets: boolean; } -export interface IError { - message?: string; - metadata?: any; - reasons?: IError[]; -} - // File Reading Settings models export interface ValidationRuleResponse { pattern: string; @@ -123,13 +117,25 @@ export interface RemoveOperationsRequest { criteria: string; } +export interface IReason { + message?: string; + metadata?: Record; + reasons?: IReason[]; +} + +export interface IError extends IReason {} + +export interface ISuccess extends IReason {} + export interface ImportResultResponse { registeredOperations: OperationResponse[]; duplicates: OperationResponse[][]; - errors: string[]; + errors: IError[]; + successes: ISuccess[]; } export interface UpdateResultResponse { updatedOperations: OperationResponse[]; - errors: string[]; + errors: IError[]; + successes: ISuccess[]; } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html index 2006ead0..07606088 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html @@ -108,16 +108,67 @@

Import Results

{{ importResult.errors.length }}
} + @if (importResult.successes.length > 0) { +
+ Successes: + {{ importResult.successes.length }} +
+ } + + @if (importResult.successes.length > 0) { +
+

Success Messages:

+ @for (success of importResult.successes; track $index) { +
+
{{ success.message || 'Success' }}
+ @if (success.metadata && getObjectKeys(success.metadata).length > 0) { + + } +
+ } +
+ } + + @if (importResult.errors.length > 0) {

Errors:

-
    - @for (error of importResult.errors; track $index) { -
  • {{ error }}
  • - } -
+ @for (error of importResult.errors; track $index) { +
+
{{ error.message || 'Error' }}
+ @if (error.metadata && getObjectKeys(error.metadata).length > 0) { + + } + @if (error.reasons && error.reasons.length > 0) { +
+ Caused by: +
    + @for (reason of error.reasons; track $index) { +
  • {{ reason.message || 'Unspecified reason' }}
  • + } +
+
+ } +
+ }
} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less index 6219ba21..801afdf4 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less @@ -105,6 +105,33 @@ &.error .stat-value { color: var(--tui-error-fill); } + + &.info .stat-value { + color: var(--tui-primary); + } +} + +.successes-section { + margin-top: 1.5rem; + + h4 { + color: var(--tui-success-fill); + margin-bottom: 1rem; + } +} + +.success-item { + margin-bottom: 1rem; + padding: 1rem; + background: var(--tui-success-bg); + border-left: 3px solid var(--tui-success-fill); + border-radius: 0.25rem; + + .success-message { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--tui-text-01); + } } .errors-section { @@ -112,7 +139,66 @@ h4 { color: var(--tui-error-fill); + margin-bottom: 1rem; + } +} + +.error-item { + margin-bottom: 1rem; + padding: 1rem; + background: var(--tui-error-bg); + border-left: 3px solid var(--tui-error-fill); + border-radius: 0.25rem; + + .error-message { + font-weight: 600; margin-bottom: 0.5rem; + color: var(--tui-text-01); + } +} + +.metadata { + margin-top: 0.75rem; + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + font-size: 0.875rem; + + strong { + display: block; + margin-bottom: 0.5rem; + color: var(--tui-text-02); + } +} + +.metadata-item { + display: flex; + gap: 0.5rem; + margin: 0.25rem 0; + + .metadata-key { + font-weight: 600; + color: var(--tui-text-02); + min-width: 120px; + } + + .metadata-value { + color: var(--tui-text-01); + font-family: monospace; + } +} + +.nested-reasons { + margin-top: 0.75rem; + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + font-size: 0.875rem; + + strong { + display: block; + margin-bottom: 0.5rem; + color: var(--tui-text-02); } ul { @@ -121,7 +207,6 @@ li { margin: 0.25rem 0; - color: var(--tui-error-fill); } } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts index 277bc6c8..5dc8bba3 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts @@ -4,7 +4,7 @@ import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { OperationsApiService } from '../operations-api.service'; import { BudgetApiService } from '../../budget/budget-api.service'; -import { BudgetResponse } from '../../budget/models'; +import { BudgetResponse, IError, ISuccess } from '../../budget/models'; import { TuiButton, TuiDialogService, @@ -38,7 +38,12 @@ export class ImportOperationsComponent implements OnInit { importForm!: FormGroup; selectedFile: File | null = null; - importResult: { registered: number; duplicates: number; errors: string[] } | null = null; + importResult: { + registered: number; + duplicates: number; + errors: IError[]; + successes: ISuccess[]; + } | null = null; constructor( private route: ActivatedRoute, @@ -108,7 +113,8 @@ export class ImportOperationsComponent implements OnInit { this.importResult = { registered: result.registeredOperations.length, duplicates: result.duplicates.length, - errors: result.errors + errors: result.errors, + successes: result.successes }; if (result.errors.length === 0) { @@ -117,7 +123,7 @@ export class ImportOperationsComponent implements OnInit { } else { const errorMessage = result.errors.length > 5 ? `Import completed with ${result.errors.length} errors. Check the results below.` - : `Import completed with errors: ${result.errors.slice(0, 3).join('; ')}`; + : `Import completed with errors. See details below.`; this.showError(errorMessage); } }, @@ -162,5 +168,10 @@ export class ImportOperationsComponent implements OnInit { dismissible: true }).subscribe(); } + + // Helper method to access Object.keys in template + getObjectKeys(obj: any): string[] { + return obj ? Object.keys(obj) : []; + } } From d3ea1106be4c192eab0156a68dfdf1b8ef0073c3 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Fri, 17 Oct 2025 09:26:21 +0300 Subject: [PATCH 092/156] feat: Add collapsible sections for errors, successes, and duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - Add toggle buttons for successes and errors sections - Add duplicates section with toggle to show/hide duplicate groups - Display duplicate operations with formatted summaries and tags - Show count badges in section headers - All sections default to collapsed except successes/errors (expanded) - Use getDuplicatesList() method to avoid template cast syntax issues Backend: - Keep transfers logic internal (not exposed in response) - Clean up redundant transfer response models UI/UX: - Section headers with toggle buttons (▼ Hide / ▶ Show) - Duplicate groups shown with operation summaries - Color-coded sections (warning theme for duplicates) - Consistent toggle pattern across all result sections - Better organization of import results for large datasets --- .../Controllers/OperationsController.cs | 7 +- .../import-operations.component.html | 64 ++++++++++++++++++- .../import-operations.component.less | 59 ++++++++++++++++- .../import-operations.component.ts | 34 +++++++++- 4 files changed, 156 insertions(+), 8 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index c1d50d32..f3dac55f 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -312,10 +312,13 @@ async IAsyncEnumerable GetOperationsAsync() if (result.IsSuccess) { + var errors = result.Reasons.Where(r => r is IError).Cast().ToList(); + var successes = result.Reasons.Where(r => r is ISuccess).Cast().ToList(); + var response = new UpdateResultResponse( result.Operations.Select(mapper.ToResponse).ToList(), - result.Errors, - result.Successes + errors, + successes ); return Ok(response); } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html index 07606088..dba2da93 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html @@ -116,10 +116,57 @@

Import Results

} + + @if (importResult.duplicates > 0) { +
+
+

Duplicates ({{ importResult.duplicates }} groups)

+ +
+ @if (showDuplicates) { + @for (group of getDuplicatesList(); track $index; let groupIdx = $index) { +
+
Duplicate Group {{ groupIdx + 1 }} ({{ group.length }} operations)
+
+ @for (op of group; track op.id) { +
+
{{ formatOperationSummary(op) }}
+
+ @for (tag of op.tags; track tag) { + {{ tag }} + } +
+
+ } +
+
+ } + } +
+ } + @if (importResult.successes.length > 0) {
-

Success Messages:

+
+

Success Messages ({{ importResult.successes.length }})

+ +
+ @if (showSuccesses) { @for (success of importResult.successes; track $index) {
{{ success.message || 'Success' }}
@@ -136,13 +183,25 @@

Success Messages:

}
} + }
} @if (importResult.errors.length > 0) {
-

Errors:

+
+

Errors ({{ importResult.errors.length }})

+ +
+ @if (showErrors) { @for (error of importResult.errors; track $index) {
{{ error.message || 'Error' }}
@@ -169,6 +228,7 @@

Errors:

}
} + }
} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less index 801afdf4..c4203810 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less @@ -111,12 +111,68 @@ } } +.section-header-with-toggle { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + + h4 { + margin: 0; + } +} + +.duplicates-section { + margin-top: 1.5rem; + + h4 { + color: var(--tui-warning-fill); + } +} + +.duplicate-group { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--tui-warning-bg); + border-left: 3px solid var(--tui-warning-fill); + border-radius: 0.25rem; + + h5 { + margin: 0 0 1rem 0; + color: var(--tui-text-01); + font-size: 1rem; + } +} + +.duplicate-operations { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.duplicate-operation { + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + + .op-summary { + margin-bottom: 0.5rem; + font-size: 0.875rem; + color: var(--tui-text-01); + } + + .op-tags { + display: flex; + gap: 0.25rem; + flex-wrap: wrap; + } +} + .successes-section { margin-top: 1.5rem; h4 { color: var(--tui-success-fill); - margin-bottom: 1rem; } } @@ -139,7 +195,6 @@ h4 { color: var(--tui-error-fill); - margin-bottom: 1rem; } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts index 5dc8bba3..ed70aecb 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts @@ -14,6 +14,7 @@ import { TuiLabel } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; +import { TuiChip } from '@taiga-ui/kit'; @Component({ selector: 'app-import-operations', @@ -26,7 +27,8 @@ import { TuiCardLarge } from '@taiga-ui/layout'; TuiTextfield, TuiLabel, TuiCardLarge, - TuiTitle + TuiTitle, + TuiChip ], templateUrl: './import-operations.component.html', styleUrls: ['./import-operations.component.less'] @@ -39,12 +41,17 @@ export class ImportOperationsComponent implements OnInit { importForm!: FormGroup; selectedFile: File | null = null; importResult: { - registered: number; + registered: number; duplicates: number; errors: IError[]; successes: ISuccess[]; } | null = null; + // Section toggles + showSuccesses = true; + showErrors = true; + showDuplicates = false; + constructor( private route: ActivatedRoute, private router: Router, @@ -117,6 +124,9 @@ export class ImportOperationsComponent implements OnInit { successes: result.successes }; + // Store full result for displaying details + (this.importResult as any).duplicatesList = result.duplicates; + if (result.errors.length === 0) { this.showSuccess(`Successfully imported ${result.registeredOperations.length} operations`); this.operationsApi.triggerRefresh(this.budgetId); @@ -173,5 +183,25 @@ export class ImportOperationsComponent implements OnInit { getObjectKeys(obj: any): string[] { return obj ? Object.keys(obj) : []; } + + toggleSuccesses(): void { + this.showSuccesses = !this.showSuccesses; + } + + toggleErrors(): void { + this.showErrors = !this.showErrors; + } + + toggleDuplicates(): void { + this.showDuplicates = !this.showDuplicates; + } + + formatOperationSummary(op: any): string { + return `${new Date(op.timestamp).toLocaleDateString()} - ${op.description} - ${op.amount.value} ${op.amount.currencyCode}`; + } + + getDuplicatesList(): any[] { + return (this.importResult as any)?.duplicatesList || []; + } } From 91184a4f3d3599b94532edc89d71974157f7fcb7 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Fri, 17 Oct 2025 23:36:47 +0300 Subject: [PATCH 093/156] feat: Add delete operations UI with criteria-based filtering Frontend: - Add collapsible delete operations section to operations list - Implement criteria expression input with textarea - Add prominent warning message about permanent deletion - Add confirmation dialog with criteria preview - Include helpful examples for common deletion criteria - Auto-refresh operations list after successful deletion - Reset form and collapse section after deletion Backend: - Revert redundant transfer response models - Keep existing DELETE endpoint functionality UI/UX: - Red-themed danger zone styling with error colors - Collapsible section (default: hidden) to prevent accidental deletions - Warning banner with alert icon - Example expressions with formatted code snippets - Destructive button appearance for delete action - Cancel button to close without deleting - Loading state during deletion Examples shown to users: - Delete all negative amounts - Delete operations containing specific text - Delete operations from specific year - Custom criteria using operation properties --- .../operations-list.component.html | 61 +++++++++++++++++++ .../operations-list.component.less | 61 +++++++++++++++++-- .../operations-list.component.ts | 43 ++++++++++++- 3 files changed, 157 insertions(+), 8 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 8f22101a..20d45166 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -80,6 +80,67 @@

Filters

+ +
+
+

Delete Operations

+ +
+ + @if (showDeleteSection) { +
+
+ ⚠️ Warning: This will permanently delete all operations matching the specified criteria. +
+ + + + + + +

+ Enter a boolean expression to filter operations for deletion. + Examples: +
o => o.Amount.Amount < 0 - All negative amounts +
o => o.Description.Contains("test") - Contains "test" +
o => o.Timestamp.Year == 2023 - From year 2023 +

+ +
+ + +
+
+ } +
+
@if (operations$ | async; as operations) { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index b6b4202b..49b18761 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -75,6 +75,49 @@ gap: 1rem; } +.delete-section { + margin-top: 2rem; + padding: 1.5rem; + background: var(--tui-error-bg-hover); + border: 2px solid var(--tui-error-fill); + border-radius: 0.5rem; +} + +.delete-header { + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + margin: 0; + color: var(--tui-error-fill); + } +} + +.delete-form { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--tui-error-fill); +} + +.warning-message { + padding: 1rem; + background: var(--tui-error-bg); + border-left: 4px solid var(--tui-error-fill); + border-radius: 0.25rem; + margin-bottom: 1rem; + + strong { + color: var(--tui-error-fill); + } +} + +.delete-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; +} + .operations-section { margin-top: 2rem; } @@ -224,12 +267,18 @@ color: var(--tui-text-02); } -// Make Object.keys available in template -:host { - ::ng-deep { - .Object { - display: none; - } +.help-text { + margin: 0.75rem 0; + color: var(--tui-text-02); + font-size: 0.875rem; + line-height: 1.6; + + code { + background: var(--tui-base-03); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.8125rem; } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 3876b216..08245d1e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -16,7 +16,7 @@ import { TuiExpand } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiChip, TuiAccordion } from '@taiga-ui/kit'; +import { TuiChip, TuiAccordion, TuiTextarea } from '@taiga-ui/kit'; @Component({ selector: 'app-operations-list', @@ -32,7 +32,8 @@ import { TuiChip, TuiAccordion } from '@taiga-ui/kit'; TuiTitle, TuiChip, TuiAccordion, - TuiExpand + TuiExpand, + TuiTextarea ], templateUrl: './operations-list.component.html', styleUrls: ['./operations-list.component.less'] @@ -44,7 +45,9 @@ export class OperationsListComponent implements OnInit { isLoading = false; filterForm!: FormGroup; + deleteForm!: FormGroup; expandedOperationId: string | null = null; + showDeleteSection = false; readonly columns = ['timestamp', 'description', 'amount', 'tags', 'actions']; @@ -66,6 +69,10 @@ export class OperationsListComponent implements OnInit { excludeTransfers: [false] }); + this.deleteForm = this.fb.group({ + criteria: ['o => true'] + }); + this.loadBudget(); this.loadOperations(); } @@ -129,6 +136,38 @@ export class OperationsListComponent implements OnInit { this.router.navigate(['/budget', this.budgetId]); } + toggleDeleteSection(): void { + this.showDeleteSection = !this.showDeleteSection; + } + + deleteOperations(): void { + if (!this.deleteForm.valid || !this.deleteForm.value.criteria) { + this.showError('Please provide a valid criteria expression'); + return; + } + + const criteria = this.deleteForm.value.criteria; + const confirmMessage = `Are you sure you want to delete all operations matching the criteria:\n\n${criteria}\n\nThis action cannot be undone.`; + + const confirmed = confirm(confirmMessage); + if (!confirmed) return; + + this.isLoading = true; + this.operationsApi.removeOperations(this.budgetId, { criteria }).subscribe({ + next: () => { + this.isLoading = false; + this.showSuccess('Operations deleted successfully'); + this.deleteForm.patchValue({ criteria: 'o => true' }); + this.showDeleteSection = false; + this.loadOperations(); + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to delete operations'); + } + }); + } + private handleError(error: any, defaultMessage: string): void { let errorMessage = defaultMessage; From 03ae449f8d7f7cc14952ad53f008d436cc8e7f11 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Fri, 17 Oct 2025 23:53:29 +0300 Subject: [PATCH 094/156] refactor: Transform operations list to clean table layout with full timestamps UI Changes: - Replace card-based layout with clean table structure - Add proper table headers (Date & Time, Description, Amount, Tags, Actions) - Display full date and time with seconds in 24-hour format - Use monospace font for timestamps and amounts - Color-code amounts (green for positive, red for negative) - Inline tags display with small chips - Expandable details row for ID, version, budget ID, and attributes - Responsive table with horizontal scroll on small screens Table Features: - Sticky header with distinct background - Hover effect on rows for better UX - Proper column widths and alignment - Right-aligned amounts column - Centered actions column - Details grid layout for metadata - Attributes displayed in grid cards Formatting: - Full timestamp: MM/DD/YYYY, HH:MM:SS (24-hour) - Amounts with 2 decimal places and currency code - Tags as compact chips - Attributes in organized grid with labels Benefits: - Cleaner, more professional appearance - Better information density - Easier to scan multiple operations - Standard table interactions (sorting potential) - More accessible and familiar UX pattern --- .../operations-list.component.html | 130 ++++++----- .../operations-list.component.less | 205 +++++++++++------- .../operations-list.component.ts | 11 +- 3 files changed, 214 insertions(+), 132 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 20d45166..5ed1d358 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -145,69 +145,83 @@

Delete Operations

@if (operations$ | async; as operations) { @if (operations.length > 0) { -
- @for (operation of operations; track operation.id) { -
-
-
- {{ formatDate(operation.timestamp) }} - {{ operation.description }} -
-
- {{ formatCurrency(operation.amount.value, operation.amount.currencyCode) }} -
- -
- - @if (expandedOperationId === operation.id) { -
-
- ID: - {{ operation.id }} -
-
- Version: - {{ operation.version }} -
-
- Budget ID: - {{ operation.budgetId }} -
- - @if (operation.tags.length > 0) { -
- Tags: -
- @for (tag of operation.tags; track tag) { - {{ tag }} - } -
+
+ + + + + + + + + + + + @for (operation of operations; track operation.id) { + + + + + + + + + @if (expandedOperationId === operation.id) { + + + + } } - - } + +
Date & TimeDescriptionAmountTagsActions
{{ formatDate(operation.timestamp) }}{{ operation.description }} + {{ formatCurrency(operation.amount.value, operation.amount.currencyCode) }} + +
+ @for (tag of operation.tags; track tag) { + {{ tag }} + }
- } - - @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { -
- Attributes: -
- @for (key of getObjectKeys(operation.attributes!); track key) { -
- {{ key }}: - {{ operation.attributes![key] }} +
+ +
+
+
+
+ ID: + {{ operation.id }} +
+
+ Version: + {{ operation.version }} +
+
+ Budget ID: + {{ operation.budgetId }} +
+
+ + @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { +
+

Attributes:

+
+ @for (key of getObjectKeys(operation.attributes!); track key) { +
+ {{ key }}: + {{ operation.attributes![key] }} +
+ } +
}
- - } - +
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 49b18761..14fa40d7 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -122,122 +122,181 @@ margin-top: 2rem; } -.operations-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.operation-card { +.operations-table-container { + overflow-x: auto; border: 1px solid var(--tui-base-04); border-radius: 0.5rem; - overflow: hidden; - transition: box-shadow 0.2s; - - &:hover { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - } } -.operation-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem; - cursor: pointer; - gap: 1rem; +.operations-table { + width: 100%; + border-collapse: collapse; - &:hover { + thead { background: var(--tui-base-02); - } -} - -.operation-main { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.operation-date { - font-size: 0.875rem; - color: var(--tui-text-02); -} - -.operation-description { - font-weight: 500; - color: var(--tui-text-01); -} - -.operation-amount { - font-size: 1.125rem; - font-weight: 700; - min-width: 120px; - text-align: right; - - &.positive { - color: var(--tui-success-fill); + + th { + padding: 1rem; + text-align: left; + font-weight: 600; + color: var(--tui-text-01); + border-bottom: 2px solid var(--tui-base-04); + white-space: nowrap; + + &.col-timestamp { + width: 180px; + } + + &.col-description { + width: auto; + min-width: 200px; + } + + &.col-amount { + width: 150px; + text-align: right; + } + + &.col-tags { + width: 200px; + } + + &.col-actions { + width: 80px; + text-align: center; + } + } } - &.negative { - color: var(--tui-error-fill); + tbody { + tr.operation-row { + border-bottom: 1px solid var(--tui-base-04); + transition: background-color 0.15s; + + &:hover { + background: var(--tui-base-02); + } + + td { + padding: 0.875rem 1rem; + vertical-align: middle; + + &.col-timestamp { + font-family: monospace; + font-size: 0.875rem; + color: var(--tui-text-02); + } + + &.col-description { + color: var(--tui-text-01); + } + + &.col-amount { + text-align: right; + font-weight: 700; + font-family: monospace; + + &.positive { + color: var(--tui-success-fill); + } + + &.negative { + color: var(--tui-error-fill); + } + } + + &.col-tags { + .tags-inline { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + } + + &.col-actions { + text-align: center; + } + } + } + + tr.operation-details-row { + background: var(--tui-base-01); + border-bottom: 1px solid var(--tui-base-04); + + td { + padding: 0; + } + } } } .operation-details { - padding: 1rem; - border-top: 1px solid var(--tui-base-04); - background: var(--tui-base-01); + padding: 1.5rem; } -.detail-row { - display: flex; +.details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; - margin-bottom: 0.75rem; - - &:last-child { - margin-bottom: 0; - } + margin-bottom: 1.5rem; +} + +.detail-item { + display: flex; + flex-direction: column; + gap: 0.25rem; } .detail-label { + font-size: 0.75rem; font-weight: 600; color: var(--tui-text-02); - min-width: 100px; + text-transform: uppercase; } .detail-value { background: var(--tui-base-02); - padding: 0.25rem 0.5rem; + padding: 0.5rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.875rem; + word-break: break-all; } -.tags-list { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; +.attributes-section { + h4 { + margin: 0 0 1rem 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--tui-text-02); + text-transform: uppercase; + } } -.attributes-list { - display: flex; - flex-direction: column; - gap: 0.5rem; +.attributes-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; } .attribute-item { display: flex; - gap: 0.5rem; - font-size: 0.875rem; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem; + background: var(--tui-base-02); + border-radius: 0.25rem; .attr-key { + font-size: 0.75rem; font-weight: 600; color: var(--tui-text-02); } .attr-value { color: var(--tui-text-01); + font-size: 0.875rem; + word-break: break-word; } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 08245d1e..6daa2c86 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -125,7 +125,16 @@ export class OperationsListComponent implements OnInit { } formatDate(timestamp: string): string { - return new Date(timestamp).toLocaleString(); + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); } navigateToImport(): void { From 245869ffc2482823fbce3b71f3e3240fe41f7952 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 00:01:50 +0300 Subject: [PATCH 095/156] feat: Add attributes column to operations table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI Changes: - Add Attributes column to main table display - Show all attributes inline in table cell without expansion - Display attributes as key-value pairs with monospace values - Show '—' when no attributes present - Simplify expanded details to only show IDs (Operation ID, Version, Budget ID) - Remove redundant attributes display from expanded section Layout: - Attributes column: 250px width - Vertical layout for multiple attributes - Compact font size (0.8125rem) for better density - Key in bold gray, value in monospace font Benefits: - All important data visible at a glance - No need to expand rows to see attributes - Better information density - Cleaner expanded details focused on technical IDs - More efficient scanning of operations with attributes --- .../operations-list.component.html | 33 +++++---- .../operations-list.component.less | 74 +++++++++---------- 2 files changed, 53 insertions(+), 54 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 5ed1d358..2f84ec31 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -153,6 +153,7 @@

Delete Operations

Description Amount Tags + Attributes Actions @@ -171,6 +172,20 @@

Delete Operations

}
+ + @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { +
+ @for (key of getObjectKeys(operation.attributes!); track key) { + + {{ key }}: + {{ operation.attributes![key] }} + + } +
+ } @else { + + } + +
+ + @if (showExamples) { +
+
+ All operations: + o => true +
+
+ Negative amounts: + o => o.Amount.Amount < 0 +
+
+ Specific year: + o => o.Timestamp.Year == 2023 +
+
+ Contains text: + o => o.Description.Contains("test") +
+
+ By tag: + o => o.Tags.Any(t => t.Value == "unwanted") +
+
+ By attribute: + o => o.Attributes.ContainsKey("error") +
+
+ Amount range: + o => o.Amount.Amount >= -100 && o.Amount.Amount <= -10 +
+
+ } +
+ +
+ + + @if (deleteExecuted) { + + } +
+ + + @if (deleteExecuted) { +
+

Deletion Completed

+
+

✓ Operations matching the criteria have been successfully deleted.

+

The operations list has been updated. You can view the remaining operations or perform another deletion.

+
+
+ } + } @else { +
Loading budget...
+ } +
+ +
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less new file mode 100644 index 00000000..ba0b56bf --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less @@ -0,0 +1,149 @@ +.delete-container { + padding: 2rem; + max-width: 900px; + margin: 0 auto; +} + +.delete-card { + padding: 2rem; +} + +.budget-info { + margin: 1.5rem 0; + padding: 1rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + + p { + margin: 0.5rem 0; + } + + code { + background: var(--tui-base-01); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-family: monospace; + } +} + +.danger-warning { + margin: 1.5rem 0; + padding: 1.5rem; + background: var(--tui-error-bg); + border: 2px solid var(--tui-error-fill); + border-radius: 0.5rem; + + h3 { + margin: 0 0 1rem 0; + color: var(--tui-error-fill); + font-size: 1.125rem; + } + + p { + margin: 0.5rem 0; + color: var(--tui-text-01); + line-height: 1.5; + } +} + +.delete-form { + margin-top: 2rem; +} + +.form-section { + margin-bottom: 2rem; + + h3 { + margin-bottom: 1rem; + } +} + +.examples-section { + margin-bottom: 2rem; + padding: 1rem; + background: var(--tui-base-02); + border-radius: 0.5rem; +} + +.examples-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + + h4 { + margin: 0; + color: var(--tui-text-02); + } +} + +.examples-list { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.example-item { + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + + strong { + display: block; + margin-bottom: 0.25rem; + color: var(--tui-text-02); + font-size: 0.875rem; + } + + code { + display: block; + background: var(--tui-base-03); + padding: 0.5rem; + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.875rem; + color: var(--tui-text-01); + } +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; + flex-wrap: wrap; +} + +.delete-result { + margin-top: 2rem; + padding: 1.5rem; + background: var(--tui-base-02); + border-radius: 0.5rem; +} + +.result-message { + margin-top: 1rem; + padding: 1.5rem; + border-radius: 0.5rem; + + &.success { + background: var(--tui-success-bg); + border-left: 4px solid var(--tui-success-fill); + + p { + margin: 0.5rem 0; + + &:first-child { + font-weight: 600; + color: var(--tui-success-fill); + } + } + } +} + +.loading-placeholder { + text-align: center; + padding: 2rem; + color: var(--tui-text-02); +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts new file mode 100644 index 00000000..fa239890 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts @@ -0,0 +1,153 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { OperationsApiService } from '../operations-api.service'; +import { BudgetApiService } from '../../budget/budget-api.service'; +import { BudgetResponse } from '../../budget/models'; +import { + TuiButton, + TuiDialogService, + TuiLoader, + TuiTitle, + TuiTextfield, + TuiLabel +} from '@taiga-ui/core'; +import { TuiCardLarge } from '@taiga-ui/layout'; +import { TuiTextarea } from '@taiga-ui/kit'; + +@Component({ + selector: 'app-delete-operations', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TuiButton, + TuiLoader, + TuiTextfield, + TuiLabel, + TuiCardLarge, + TuiTitle, + TuiTextarea + ], + templateUrl: './delete-operations.component.html', + styleUrls: ['./delete-operations.component.less'] +}) +export class DeleteOperationsComponent implements OnInit { + budgetId!: string; + budget: BudgetResponse | null = null; + isLoading = false; + + deleteForm!: FormGroup; + deleteExecuted = false; + + // Section toggles + showExamples = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private operationsApi: OperationsApiService, + private budgetApi: BudgetApiService, + private fb: FormBuilder, + private dialogService: TuiDialogService + ) {} + + ngOnInit(): void { + this.budgetId = this.route.snapshot.params['budgetId']; + + this.deleteForm = this.fb.group({ + criteria: ['o => true', Validators.required] + }); + + this.loadBudget(); + } + + loadBudget(): void { + this.isLoading = true; + this.budgetApi.getBudgetById(this.budgetId).subscribe({ + next: (budget) => { + this.budget = budget || null; + this.isLoading = false; + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to load budget'); + } + }); + } + + toggleExamples(): void { + this.showExamples = !this.showExamples; + } + + deleteOperations(): void { + if (!this.deleteForm.valid || !this.budget) { + this.showError('Please provide a valid criteria expression'); + return; + } + + const criteria = this.deleteForm.value.criteria; + const confirmMessage = `Are you sure you want to delete all operations matching the criteria:\n\n${criteria}\n\nThis action cannot be undone.`; + + const confirmed = confirm(confirmMessage); + if (!confirmed) return; + + this.isLoading = true; + this.deleteExecuted = false; + + this.operationsApi.removeOperations(this.budgetId, { criteria }).subscribe({ + next: () => { + this.isLoading = false; + this.deleteExecuted = true; + this.showSuccess('Operations deleted successfully'); + this.operationsApi.triggerRefresh(this.budgetId); + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to delete operations'); + } + }); + } + + viewOperations(): void { + this.router.navigate(['/budget', this.budgetId, 'operations']); + } + + resetForm(): void { + this.deleteForm.patchValue({ criteria: 'o => true' }); + this.deleteExecuted = false; + } + + private handleError(error: any, defaultMessage: string): void { + let errorMessage = defaultMessage; + + if (error.status === 400 && Array.isArray(error.error)) { + const errors = error.error as any[]; + errorMessage = errors.map(e => e.message || e).join('; '); + } else if (error.error?.message) { + errorMessage = error.error.message; + } + + this.showError(errorMessage); + } + + private showError(message: string): void { + this.dialogService.open(message, { + label: 'Error', + size: 'm', + closeable: true, + dismissible: true + }).subscribe(); + } + + private showSuccess(message: string): void { + this.dialogService.open(message, { + label: 'Success', + size: 's', + closeable: true, + dismissible: true + }).subscribe(); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 828c6068..438a38b6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -11,6 +11,14 @@

Operations

(click)="navigateToImport()"> Import Operations +
- -
-
-

Delete Operations

- -
- - @if (showDeleteSection) { -
-
- ⚠️ Warning: This will permanently delete all operations matching the specified criteria. -
- - - - - - -

- Enter a boolean expression to filter operations for deletion. - Examples: -
o => o.Amount.Amount < 0 - All negative amounts -
o => o.Description.Contains("test") - Contains "test" -
o => o.Timestamp.Year == 2023 - From year 2023 -

- -
- - -
-
- } -
-
@if (operations$ | async; as operations) { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 4036d054..d5015918 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -75,49 +75,6 @@ gap: 1rem; } -.delete-section { - margin-top: 2rem; - padding: 1.5rem; - background: var(--tui-error-bg-hover); - border: 2px solid var(--tui-error-fill); - border-radius: 0.5rem; -} - -.delete-header { - display: flex; - justify-content: space-between; - align-items: center; - - h3 { - margin: 0; - color: var(--tui-error-fill); - } -} - -.delete-form { - margin-top: 1rem; - padding-top: 1rem; - border-top: 1px solid var(--tui-error-fill); -} - -.warning-message { - padding: 1rem; - background: var(--tui-error-bg); - border-left: 4px solid var(--tui-error-fill); - border-radius: 0.25rem; - margin-bottom: 1rem; - - strong { - color: var(--tui-error-fill); - } -} - -.delete-actions { - display: flex; - gap: 1rem; - margin-top: 1.5rem; -} - .operations-section { margin-top: 2rem; } @@ -345,18 +302,3 @@ color: var(--tui-text-02); } -.help-text { - margin: 0.75rem 0; - color: var(--tui-text-02); - font-size: 0.875rem; - line-height: 1.6; - - code { - background: var(--tui-base-03); - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; - font-family: monospace; - font-size: 0.8125rem; - } -} - diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 6daa2c86..c29e1f7c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -16,7 +16,7 @@ import { TuiExpand } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiChip, TuiAccordion, TuiTextarea } from '@taiga-ui/kit'; +import { TuiChip, TuiAccordion } from '@taiga-ui/kit'; @Component({ selector: 'app-operations-list', @@ -32,8 +32,7 @@ import { TuiChip, TuiAccordion, TuiTextarea } from '@taiga-ui/kit'; TuiTitle, TuiChip, TuiAccordion, - TuiExpand, - TuiTextarea + TuiExpand ], templateUrl: './operations-list.component.html', styleUrls: ['./operations-list.component.less'] @@ -45,9 +44,7 @@ export class OperationsListComponent implements OnInit { isLoading = false; filterForm!: FormGroup; - deleteForm!: FormGroup; expandedOperationId: string | null = null; - showDeleteSection = false; readonly columns = ['timestamp', 'description', 'amount', 'tags', 'actions']; @@ -69,10 +66,6 @@ export class OperationsListComponent implements OnInit { excludeTransfers: [false] }); - this.deleteForm = this.fb.group({ - criteria: ['o => true'] - }); - this.loadBudget(); this.loadOperations(); } @@ -145,36 +138,8 @@ export class OperationsListComponent implements OnInit { this.router.navigate(['/budget', this.budgetId]); } - toggleDeleteSection(): void { - this.showDeleteSection = !this.showDeleteSection; - } - - deleteOperations(): void { - if (!this.deleteForm.valid || !this.deleteForm.value.criteria) { - this.showError('Please provide a valid criteria expression'); - return; - } - - const criteria = this.deleteForm.value.criteria; - const confirmMessage = `Are you sure you want to delete all operations matching the criteria:\n\n${criteria}\n\nThis action cannot be undone.`; - - const confirmed = confirm(confirmMessage); - if (!confirmed) return; - - this.isLoading = true; - this.operationsApi.removeOperations(this.budgetId, { criteria }).subscribe({ - next: () => { - this.isLoading = false; - this.showSuccess('Operations deleted successfully'); - this.deleteForm.patchValue({ criteria: 'o => true' }); - this.showDeleteSection = false; - this.loadOperations(); - }, - error: (error) => { - this.isLoading = false; - this.handleError(error, 'Failed to delete operations'); - } - }); + navigateToDelete(): void { + this.router.navigate(['/budget', this.budgetId, 'operations', 'delete']); } private handleError(error: any, defaultMessage: string): void { From c76a9670e34806ec06abbde5aaee8ef31196656c Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 15:36:14 +0300 Subject: [PATCH 099/156] feat: Return structured results from delete endpoint with successes and errors Backend: - Add DeleteResultResponse model with IError and ISuccess collections - Update DELETE endpoint to return 200 OK with results instead of 204 No Content - Extract successes and errors from Result.Reasons - Return detailed metadata and nested reasons in response Frontend: - Update DeleteResultResponse TypeScript model - Change removeOperations return type from void to DeleteResultResponse - Display deletion results similar to import screen - Add collapsible successes and errors sections with toggles - Show stat counters for successes and errors - Display metadata and nested reasons for each error/success - Add helper method getObjectKeys for template access Results Display: - Stats section showing success/error counts - Collapsible sections (default: expanded) - Success items with green theme and metadata - Error items with red theme, metadata, and nested reasons - Section headers with counts and toggle buttons - Consistent styling with import results screen Benefits: - Users see detailed feedback about what was deleted - Error details help debug issues - Success metadata provides confirmation - Consistent UX across import/update/delete operations - Better transparency in deletion process --- .../Controllers/OperationsController.cs | 10 +- .../Models/OperationResponse.cs | 5 + .../budget-client/src/app/budget/models.ts | 5 + .../delete-operations.component.html | 102 +++++++++++- .../delete-operations.component.less | 148 ++++++++++++++++-- .../delete-operations.component.ts | 45 +++++- .../app/operations/operations-api.service.ts | 7 +- 7 files changed, 288 insertions(+), 34 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index f3dac55f..785f02f2 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -332,10 +332,10 @@ async IAsyncEnumerable GetOperationsAsync() /// Budget ID from route /// Remove operations request with criteria expression /// Cancellation token - /// Success or error details + /// Delete result with success/failure details [HttpDelete] [Consumes("application/json", "application/yaml", "text/yaml")] - [ProducesResponseType(204)] + [ProducesResponseType(typeof(DeleteResultResponse), 200)] [ProducesResponseType(typeof(IEnumerable), 400)] [ProducesResponseType(typeof(IEnumerable), 404)] public async Task RemoveOperations( @@ -364,7 +364,11 @@ public async Task RemoveOperations( if (result.IsSuccess) { - return NoContent(); + var errors = result.Reasons.Where(r => r is IError).Cast().ToList(); + var successes = result.Reasons.Where(r => r is ISuccess).Cast().ToList(); + + var response = new DeleteResultResponse(errors, successes); + return Ok(response); } return BadRequest(result.Errors); diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs index 6383d2a3..8869e02e 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs @@ -59,3 +59,8 @@ public record UpdateResultResponse( IReadOnlyCollection Successes ); +public record DeleteResultResponse( + IReadOnlyCollection Errors, + IReadOnlyCollection Successes +); + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts index 6ad969d9..acbaf828 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts @@ -138,4 +138,9 @@ export interface UpdateResultResponse { updatedOperations: OperationResponse[]; errors: IError[]; successes: ISuccess[]; +} + +export interface DeleteResultResponse { + errors: IError[]; + successes: ISuccess[]; } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html index 3dd0f035..310b7ec5 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html @@ -94,7 +94,7 @@

Common Examples

(click)="viewOperations()"> View Operations List - @if (deleteExecuted) { + @if (deleteResult) {
- @if (deleteExecuted) { + @if (deleteResult) {
-

Deletion Completed

-
-

✓ Operations matching the criteria have been successfully deleted.

-

The operations list has been updated. You can view the remaining operations or perform another deletion.

+

Deletion Results

+
+ @if (deleteResult.successes.length > 0) { +
+ Successes: + {{ deleteResult.successes.length }} +
+ } + @if (deleteResult.errors.length > 0) { +
+ Errors: + {{ deleteResult.errors.length }} +
+ }
+ + + @if (deleteResult.successes.length > 0) { +
+
+

Success Messages ({{ deleteResult.successes.length }})

+ +
+ @if (showSuccesses) { + @for (success of deleteResult.successes; track $index) { +
+
{{ success.message || 'Success' }}
+ @if (success.metadata && getObjectKeys(success.metadata).length > 0) { + + } +
+ } + } +
+ } + + + @if (deleteResult.errors.length > 0) { +
+
+

Errors ({{ deleteResult.errors.length }})

+ +
+ @if (showErrors) { + @for (error of deleteResult.errors; track $index) { +
+
{{ error.message || 'Error' }}
+ @if (error.metadata && getObjectKeys(error.metadata).length > 0) { + + } + @if (error.reasons && error.reasons.length > 0) { +
+ Caused by: +
    + @for (reason of error.reasons; track $index) { +
  • {{ reason.message || 'Unspecified reason' }}
  • + } +
+
+ } +
+ } + } +
+ }
} } @else { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less index ba0b56bf..15f826bd 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less @@ -121,22 +121,142 @@ border-radius: 0.5rem; } -.result-message { - margin-top: 1rem; - padding: 1.5rem; - border-radius: 0.5rem; +.result-stats { + display: flex; + gap: 2rem; + margin: 1rem 0; + flex-wrap: wrap; +} + +.stat-item { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .stat-label { + font-size: 0.875rem; + color: var(--tui-text-02); + } + + .stat-value { + font-size: 1.5rem; + font-weight: 700; + } + + &.success .stat-value { + color: var(--tui-success-fill); + } + + &.error .stat-value { + color: var(--tui-error-fill); + } +} + +.section-header-with-toggle { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + + h4 { + margin: 0; + } +} + +.successes-section { + margin-top: 1.5rem; + + h4 { + color: var(--tui-success-fill); + } +} + +.success-item { + margin-bottom: 1rem; + padding: 1rem; + background: var(--tui-success-bg); + border-left: 3px solid var(--tui-success-fill); + border-radius: 0.25rem; + + .success-message { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--tui-text-01); + } +} + +.errors-section { + margin-top: 1.5rem; - &.success { - background: var(--tui-success-bg); - border-left: 4px solid var(--tui-success-fill); + h4 { + color: var(--tui-error-fill); + } +} + +.error-item { + margin-bottom: 1rem; + padding: 1rem; + background: var(--tui-error-bg); + border-left: 3px solid var(--tui-error-fill); + border-radius: 0.25rem; + + .error-message { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--tui-text-01); + } +} + +.metadata { + margin-top: 0.75rem; + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + font-size: 0.875rem; + + strong { + display: block; + margin-bottom: 0.5rem; + color: var(--tui-text-02); + } +} + +.metadata-item { + display: flex; + gap: 0.5rem; + margin: 0.25rem 0; + + .metadata-key { + font-weight: 600; + color: var(--tui-text-02); + min-width: 120px; + } + + .metadata-value { + color: var(--tui-text-01); + font-family: monospace; + } +} + +.nested-reasons { + margin-top: 0.75rem; + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + font-size: 0.875rem; + + strong { + display: block; + margin-bottom: 0.5rem; + color: var(--tui-text-02); + } + + ul { + margin: 0; + padding-left: 1.5rem; - p { - margin: 0.5rem 0; - - &:first-child { - font-weight: 600; - color: var(--tui-success-fill); - } + li { + margin: 0.25rem 0; } } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts index fa239890..74b6a462 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts @@ -4,7 +4,7 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angula import { ActivatedRoute, Router } from '@angular/router'; import { OperationsApiService } from '../operations-api.service'; import { BudgetApiService } from '../../budget/budget-api.service'; -import { BudgetResponse } from '../../budget/models'; +import { BudgetResponse, IError, ISuccess } from '../../budget/models'; import { TuiButton, TuiDialogService, @@ -39,10 +39,15 @@ export class DeleteOperationsComponent implements OnInit { isLoading = false; deleteForm!: FormGroup; - deleteExecuted = false; + deleteResult: { + errors: IError[]; + successes: ISuccess[]; + } | null = null; // Section toggles showExamples = false; + showSuccesses = true; + showErrors = true; constructor( private route: ActivatedRoute, @@ -94,14 +99,25 @@ export class DeleteOperationsComponent implements OnInit { if (!confirmed) return; this.isLoading = true; - this.deleteExecuted = false; + this.deleteResult = null; this.operationsApi.removeOperations(this.budgetId, { criteria }).subscribe({ - next: () => { + next: (result) => { this.isLoading = false; - this.deleteExecuted = true; - this.showSuccess('Operations deleted successfully'); - this.operationsApi.triggerRefresh(this.budgetId); + this.deleteResult = { + errors: result.errors, + successes: result.successes + }; + + if (result.errors.length === 0) { + this.showSuccess('Operations deleted successfully'); + this.operationsApi.triggerRefresh(this.budgetId); + } else { + const errorMessage = result.errors.length > 5 + ? `Deletion completed with ${result.errors.length} errors. Check the results below.` + : `Deletion completed with errors. See details below.`; + this.showError(errorMessage); + } }, error: (error) => { this.isLoading = false; @@ -116,7 +132,20 @@ export class DeleteOperationsComponent implements OnInit { resetForm(): void { this.deleteForm.patchValue({ criteria: 'o => true' }); - this.deleteExecuted = false; + this.deleteResult = null; + } + + toggleSuccesses(): void { + this.showSuccesses = !this.showSuccesses; + } + + toggleErrors(): void { + this.showErrors = !this.showErrors; + } + + // Helper method to access Object.keys in template + getObjectKeys(obj: any): string[] { + return obj ? Object.keys(obj) : []; } private handleError(error: any, defaultMessage: string): void { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts index 07fc64b4..b1795e60 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts @@ -6,7 +6,8 @@ import { UpdateOperationsRequest, RemoveOperationsRequest, ImportResultResponse, - UpdateResultResponse + UpdateResultResponse, + DeleteResultResponse } from '../budget/models'; import { environment } from '../../environments/environment'; @@ -96,9 +97,9 @@ export class OperationsApiService { /** * Remove operations matching criteria */ - removeOperations(budgetId: string, request: RemoveOperationsRequest): Observable { + removeOperations(budgetId: string, request: RemoveOperationsRequest): Observable { const headers = new HttpHeaders().set('Content-Type', 'application/json'); - return this.http.request( + return this.http.request( 'DELETE', `${this.baseUrl}/budget/${budgetId}/operations`, { From 352155624c99018ba3130a888e3ffa6bb0d3fc25 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 15:39:53 +0300 Subject: [PATCH 100/156] chore: simplified controller --- .../Controllers/OperationsController.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index 785f02f2..7017d52e 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -364,10 +364,7 @@ public async Task RemoveOperations( if (result.IsSuccess) { - var errors = result.Reasons.Where(r => r is IError).Cast().ToList(); - var successes = result.Reasons.Where(r => r is ISuccess).Cast().ToList(); - - var response = new DeleteResultResponse(errors, successes); + var response = new DeleteResultResponse(result.Errors, result.Successes); return Ok(response); } From e3f45ebe935826faa153ff1e38fd04407e7318c2 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 15:51:46 +0300 Subject: [PATCH 101/156] feat: Improve operations list filters with textarea, dropdown, and keyboard shortcut Backend: - Add CurrenciesController with GET endpoint - Return all available currencies from NMoneys (Currency.FindAll()) - Return sorted list of ISO currency codes Frontend - Operations List: 1. Reduce spacing between budget info and filters - Shrink budget info margin from 1.5rem to 1rem - Reduce budget info padding and font size - Tighter filters section margin (1.5rem vs 2rem) 2. Enhanced criteria input - Change from single-line input to textarea (2 rows) - Add keyboard shortcut: Ctrl+Enter to apply filters - Show hint in label: 'Criteria Expression (Ctrl+Enter to apply)' - Full-width layout for better expression editing - Consistent with delete operations screen 3. Output currency dropdown - Replace text input with proper select dropdown - Load currencies from /api/v0.1/currencies endpoint - Show '-- Original --' option for null/no conversion - Fallback to common currencies if API fails - Styled to match Taiga UI theme Layout: - 3-column grid for filters - Criteria input spans full width (grid-column: 1 / -1) - Currency dropdown in first column - Exclude transfers checkbox in second column - Form submit on Enter in any field (with Ctrl for textarea) Benefits: - Easier to write complex criteria expressions - Faster filtering with keyboard shortcut - Proper currency selection with all ISO currencies - Cleaner, more compact layout - Better UX consistency across screens --- .../Controllers/CurrenciesController.cs | 27 ++++++++++++ .../operations-list.component.html | 43 +++++++++++-------- .../operations-list.component.less | 36 +++++++++++++--- .../operations-list.component.ts | 38 ++++++++++++++-- 4 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/CurrenciesController.cs diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CurrenciesController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CurrenciesController.cs new file mode 100644 index 00000000..a0fc5568 --- /dev/null +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CurrenciesController.cs @@ -0,0 +1,27 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NMoneys; + +namespace NVs.Budget.Controllers.Web.Controllers; + +[Authorize] +[ApiVersion("0.1")] +[Route("api/v{version:apiVersion}/[controller]")] +public class CurrenciesController : Controller +{ + /// + /// Gets all available currencies + /// + /// Collection of currency codes + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyCollection), 200)] + public IReadOnlyCollection GetCurrencies() + { + return Currency.FindAll() + .Select(c => c.IsoCode.ToString()) + .OrderBy(c => c) + .ToList(); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 438a38b6..c99b36a6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -37,30 +37,35 @@

Operations

} -
+

Filters

- - - - +
+ + + + +
- +
- - + +
-
+
- - + + + +
-
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 21fcad30..75e28b4e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -58,41 +58,13 @@ } } -.currency-select { - width: 100%; - padding: 0.625rem 1rem; - border: 1px solid var(--tui-base-04); - border-radius: 0.5rem; - background: var(--tui-base-01); - color: var(--tui-text-01); - font-size: 0.9375rem; - cursor: pointer; - - &:hover { - border-color: var(--tui-base-05); - } - - &:focus { - outline: none; - border-color: var(--tui-primary); - } -} - .checkbox-field { display: flex; align-items: center; + padding-top: 1.5rem; label { - display: flex; - align-items: center; - gap: 0.5rem; cursor: pointer; - - input[type="checkbox"] { - width: 1.25rem; - height: 1.25rem; - cursor: pointer; - } } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index dae2aecf..f043d046 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -18,8 +18,8 @@ import { TuiExpand } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiChip, TuiAccordion, TuiTextarea, TuiDataListWrapper } from '@taiga-ui/kit'; -import { TuiDataList } from '@taiga-ui/core'; +import { TuiChip, TuiAccordion, TuiTextarea, TuiDataListWrapper, TuiCheckbox } from '@taiga-ui/kit'; +import { TuiDataList, TuiDropdown } from '@taiga-ui/core'; @Component({ selector: 'app-operations-list', @@ -38,7 +38,9 @@ import { TuiDataList } from '@taiga-ui/core'; TuiExpand, TuiTextarea, TuiDataList, - TuiDataListWrapper + TuiDataListWrapper, + TuiCheckbox, + TuiDropdown ], templateUrl: './operations-list.component.html', styleUrls: ['./operations-list.component.less'] From 6f60069b4ee9eb8a13c000ff4764b36c39c75170 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 16:28:59 +0300 Subject: [PATCH 103/156] fix: Currency dropdown not populating - fix value binding issue --- .../Controllers/OperationsController.cs | 2 +- .../operations/operations-list/operations-list.component.html | 2 +- .../app/operations/operations-list/operations-list.component.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index 7017d52e..4b9bd10f 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -113,7 +113,7 @@ public async IAsyncEnumerable GetOperations( [ProducesResponseType(typeof(IEnumerable), 404)] public async Task ImportOperations( [FromRoute] Guid budgetId, - [FromForm] IFormFile file, + IFormFile file, [FromForm] string budgetVersion, [FromForm] string? transferConfidenceLevel = null, [FromForm] string? filePattern = null, diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index cfc638f5..b55a397c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -59,7 +59,7 @@

Filters

diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 360a04d6..02ea2caa 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -85,6 +85,7 @@ export class OperationsListComponent implements OnInit { const baseUrl = environment.apiUrl + '/api/v0.1'; this.http.get(`${baseUrl}/currencies`, { withCredentials: true }).subscribe({ next: (currencies) => { + console.log('Loaded currencies:', currencies.length, currencies.slice(0, 5)); this.currencies = currencies; }, error: (error) => { From 9490f1c3abef90ae6189c07d2284401b8862fe1b Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 21:42:37 +0300 Subject: [PATCH 105/156] fix: Use tui-combo-box for currency dropdown with proper Taiga UI pattern Changes: - Replace native select with tui-combo-box component - Use tui-data-list-wrapper for dropdown items - Add tuiTextfieldCleaner for clear button - Remove stringify property (not needed for string arrays) - Remove custom CSS for select (now using Taiga UI styles) - Clean up unused imports Features: - Searchable dropdown with type-ahead filtering - Clear button to reset to empty/original - Proper Taiga UI styling and animations - Accessible dropdown with keyboard navigation - Shows all 191 currencies from NMoneys The dropdown now properly displays all currencies and supports: - Typing to search/filter - Click to select - Clear button to reset - Keyboard navigation --- .../operations-list.component.html | 20 +++++++++---------- .../operations-list.component.ts | 8 +++++++- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index d54f254d..9c239fc1 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -54,17 +54,15 @@

Filters

- - - - + + Output Currency + +
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 02ea2caa..efd1198c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -18,7 +18,13 @@ import { TuiExpand } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiChip, TuiAccordion, TuiTextarea, TuiDataListWrapper, TuiCheckbox } from '@taiga-ui/kit'; +import { + TuiChip, + TuiAccordion, + TuiTextarea, + TuiDataListWrapper, + TuiCheckbox +} from '@taiga-ui/kit'; import { TuiDataList, TuiDropdown } from '@taiga-ui/core'; @Component({ From de0f10125d37973d73cf1d662c09bec3002de07d Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 21:43:11 +0300 Subject: [PATCH 106/156] cleanup: Remove debug console.log from currency loading --- .../app/operations/operations-list/operations-list.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index efd1198c..8578f581 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -91,7 +91,6 @@ export class OperationsListComponent implements OnInit { const baseUrl = environment.apiUrl + '/api/v0.1'; this.http.get(`${baseUrl}/currencies`, { withCredentials: true }).subscribe({ next: (currencies) => { - console.log('Loaded currencies:', currencies.length, currencies.slice(0, 5)); this.currencies = currencies; }, error: (error) => { From f2c30ad58584296f5d5e9916ed090a8803a7fcfc Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 21:49:43 +0300 Subject: [PATCH 107/156] revert: Change currency dropdown back to text input Revert tui-combo-box implementation due to rendering issues. Use simple tui-textfield with text input instead. Changes: - Remove tui-combo-box and related imports - Remove currency loading from API - Use text input for currency code entry - Remove TuiDataList, TuiDataListWrapper, TuiComboBox imports - Remove HttpClient dependency and environment import - Clean up unused currencies array and loadCurrencies method Benefits of text input: - Simple and reliable - Users can type currency codes directly - No complex dropdown state management - Consistent with criteria input pattern - Works reliably across all browsers --- .../operations-list.component.html | 16 ++++----- .../operations-list.component.ts | 35 ++----------------- 2 files changed, 11 insertions(+), 40 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 9c239fc1..b63a9cca 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -54,15 +54,15 @@

Filters

- - Output Currency - + + - +
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 8578f581..eb9e14a8 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -3,11 +3,9 @@ import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable, catchError, of } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; import { OperationsApiService } from '../operations-api.service'; import { BudgetApiService } from '../../budget/budget-api.service'; import { BudgetResponse, OperationResponse } from '../../budget/models'; -import { environment } from '../../../environments/environment'; import { TuiButton, TuiDialogService, @@ -18,14 +16,7 @@ import { TuiExpand } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { - TuiChip, - TuiAccordion, - TuiTextarea, - TuiDataListWrapper, - TuiCheckbox -} from '@taiga-ui/kit'; -import { TuiDataList, TuiDropdown } from '@taiga-ui/core'; +import { TuiChip, TuiAccordion, TuiTextarea, TuiCheckbox } from '@taiga-ui/kit'; @Component({ selector: 'app-operations-list', @@ -43,10 +34,7 @@ import { TuiDataList, TuiDropdown } from '@taiga-ui/core'; TuiAccordion, TuiExpand, TuiTextarea, - TuiDataList, - TuiDataListWrapper, - TuiCheckbox, - TuiDropdown + TuiCheckbox ], templateUrl: './operations-list.component.html', styleUrls: ['./operations-list.component.less'] @@ -59,7 +47,6 @@ export class OperationsListComponent implements OnInit { filterForm!: FormGroup; expandedOperationId: string | null = null; - currencies: string[] = []; readonly columns = ['timestamp', 'description', 'amount', 'tags', 'actions']; @@ -69,8 +56,7 @@ export class OperationsListComponent implements OnInit { private operationsApi: OperationsApiService, private budgetApi: BudgetApiService, private fb: FormBuilder, - private dialogService: TuiDialogService, - private http: HttpClient + private dialogService: TuiDialogService ) {} ngOnInit(): void { @@ -84,21 +70,6 @@ export class OperationsListComponent implements OnInit { this.loadBudget(); this.loadOperations(); - this.loadCurrencies(); - } - - loadCurrencies(): void { - const baseUrl = environment.apiUrl + '/api/v0.1'; - this.http.get(`${baseUrl}/currencies`, { withCredentials: true }).subscribe({ - next: (currencies) => { - this.currencies = currencies; - }, - error: (error) => { - console.error('Failed to load currencies', error); - // Fallback to common currencies - this.currencies = ['USD', 'EUR', 'GBP', 'JPY', 'RUB', 'CNY']; - } - }); } loadBudget(): void { From 0f4f5a9b3656bdaf0985c074b3ff672330c6ec62 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 21:50:13 +0300 Subject: [PATCH 108/156] cleanup: Remove unused CurrenciesController Since we reverted to text input for currency selection, the CurrenciesController is no longer needed. --- .../Controllers/CurrenciesController.cs | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 src/Controllers/NVs.Budget.Controllers.Web/Controllers/CurrenciesController.cs diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CurrenciesController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CurrenciesController.cs deleted file mode 100644 index a0fc5568..00000000 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/CurrenciesController.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Asp.Versioning; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using NMoneys; - -namespace NVs.Budget.Controllers.Web.Controllers; - -[Authorize] -[ApiVersion("0.1")] -[Route("api/v{version:apiVersion}/[controller]")] -public class CurrenciesController : Controller -{ - /// - /// Gets all available currencies - /// - /// Collection of currency codes - [HttpGet] - [ProducesResponseType(typeof(IReadOnlyCollection), 200)] - public IReadOnlyCollection GetCurrencies() - { - return Currency.FindAll() - .Select(c => c.IsoCode.ToString()) - .OrderBy(c => c) - .ToList(); - } -} - From 3f03a42085f8bf7fcc97b41e7ac8c3424a5461c0 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 21:59:35 +0300 Subject: [PATCH 109/156] refactor: Remove redundant budget property from operations-list Cleanup operations-list component by removing unnecessary budget loading: - Remove budget property and BudgetResponse type - Remove loadBudget() method - Remove BudgetApiService dependency - Remove budget info display from template - Remove budget-info CSS styles Benefits: - Simpler component with less state - One less API call on component load - Budget ID already available from route params - Cleaner, more focused component - Reduced bundle size (1.81 kB saved) --- .../operations-list.component.html | 6 ------ .../operations-list.component.less | 12 ------------ .../operations-list.component.ts | 17 +---------------- 3 files changed, 1 insertion(+), 34 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index b63a9cca..78c18896 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -30,12 +30,6 @@

Operations

- @if (budget) { -
-

Budget: {{ budget.name }}

-
- } -

Filters

diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 75e28b4e..32fdebf0 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -22,18 +22,6 @@ gap: 1rem; } -.budget-info { - margin-bottom: 1rem; - padding: 0.75rem 1rem; - background: var(--tui-base-02); - border-radius: 0.5rem; - - p { - margin: 0; - font-size: 0.875rem; - } -} - .filters-section { margin-bottom: 1.5rem; padding: 1.5rem; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index eb9e14a8..5dd6898e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -4,8 +4,7 @@ import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable, catchError, of } from 'rxjs'; import { OperationsApiService } from '../operations-api.service'; -import { BudgetApiService } from '../../budget/budget-api.service'; -import { BudgetResponse, OperationResponse } from '../../budget/models'; +import { OperationResponse } from '../../budget/models'; import { TuiButton, TuiDialogService, @@ -41,7 +40,6 @@ import { TuiChip, TuiAccordion, TuiTextarea, TuiCheckbox } from '@taiga-ui/kit'; }) export class OperationsListComponent implements OnInit { budgetId!: string; - budget: BudgetResponse | null = null; operations$!: Observable; isLoading = false; @@ -54,7 +52,6 @@ export class OperationsListComponent implements OnInit { private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private budgetApi: BudgetApiService, private fb: FormBuilder, private dialogService: TuiDialogService ) {} @@ -68,21 +65,9 @@ export class OperationsListComponent implements OnInit { excludeTransfers: [false] }); - this.loadBudget(); this.loadOperations(); } - loadBudget(): void { - this.budgetApi.getBudgetById(this.budgetId).subscribe({ - next: (budget) => { - this.budget = budget || null; - }, - error: (error) => { - this.handleError(error, 'Failed to load budget'); - } - }); - } - loadOperations(): void { const formValue = this.filterForm.value; this.operations$ = this.operationsApi.getOperations( From 82ea118da9ea050d862fa806199f05e21fc9d640 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 22:03:05 +0300 Subject: [PATCH 110/156] refactor: Remove redundant budget info from import and delete operations Cleanup import-operations and delete-operations components: Import Operations: - Keep budget loading (needed for version in API call) - Remove budget info display section from template - Remove budget-info CSS styles Delete Operations: - Remove budget property and BudgetResponse type - Remove loadBudget() method - Remove BudgetApiService dependency - Remove budget validation check (redundant) - Remove budget info display and loading placeholder - Remove budget-info CSS styles Benefits: - Cleaner UI with less redundant information - Simpler delete component (one less API call) - Import component still loads budget but doesn't display it - Bundle size reduced by ~1.3 kB - More focused components --- .../delete-operations.component.html | 11 +-------- .../delete-operations.component.less | 18 --------------- .../delete-operations.component.ts | 23 ++----------------- .../import-operations.component.html | 5 ---- .../import-operations.component.less | 18 --------------- 5 files changed, 3 insertions(+), 72 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html index 310b7ec5..e35b37f1 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html @@ -3,13 +3,7 @@

Delete Operations

- @if (budget) { -
-

Budget: {{ budget.name }}

-

Budget ID: {{ budget.id }}

-
- -
+

⚠️ Warning: Dangerous Operation

This action will permanently delete all operations matching your criteria. This cannot be undone.

Please be careful and verify your criteria before executing the deletion.

@@ -206,9 +200,6 @@

Errors ({{ deleteResult.errors.length }})

}
} - } @else { -
Loading budget...
- }
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less index 15f826bd..6c1d08d1 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less @@ -8,24 +8,6 @@ padding: 2rem; } -.budget-info { - margin: 1.5rem 0; - padding: 1rem; - background: var(--tui-base-02); - border-radius: 0.5rem; - - p { - margin: 0.5rem 0; - } - - code { - background: var(--tui-base-01); - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-family: monospace; - } -} - .danger-warning { margin: 1.5rem 0; padding: 1.5rem; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts index 74b6a462..92e1014b 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts @@ -3,8 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { OperationsApiService } from '../operations-api.service'; -import { BudgetApiService } from '../../budget/budget-api.service'; -import { BudgetResponse, IError, ISuccess } from '../../budget/models'; +import { IError, ISuccess } from '../../budget/models'; import { TuiButton, TuiDialogService, @@ -35,7 +34,6 @@ import { TuiTextarea } from '@taiga-ui/kit'; }) export class DeleteOperationsComponent implements OnInit { budgetId!: string; - budget: BudgetResponse | null = null; isLoading = false; deleteForm!: FormGroup; @@ -53,7 +51,6 @@ export class DeleteOperationsComponent implements OnInit { private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private budgetApi: BudgetApiService, private fb: FormBuilder, private dialogService: TuiDialogService ) {} @@ -64,22 +61,6 @@ export class DeleteOperationsComponent implements OnInit { this.deleteForm = this.fb.group({ criteria: ['o => true', Validators.required] }); - - this.loadBudget(); - } - - loadBudget(): void { - this.isLoading = true; - this.budgetApi.getBudgetById(this.budgetId).subscribe({ - next: (budget) => { - this.budget = budget || null; - this.isLoading = false; - }, - error: (error) => { - this.isLoading = false; - this.handleError(error, 'Failed to load budget'); - } - }); } toggleExamples(): void { @@ -87,7 +68,7 @@ export class DeleteOperationsComponent implements OnInit { } deleteOperations(): void { - if (!this.deleteForm.valid || !this.budget) { + if (!this.deleteForm.valid) { this.showError('Please provide a valid criteria expression'); return; } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html index dba2da93..21237b82 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html @@ -4,11 +4,6 @@

Import Operations

@if (budget) { -
-

Budget: {{ budget.name }}

-

Budget ID: {{ budget.id }}

-
-

Upload CSV File

diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less index c4203810..122f032c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less @@ -8,24 +8,6 @@ padding: 2rem; } -.budget-info { - margin: 1.5rem 0; - padding: 1rem; - background: var(--tui-base-02); - border-radius: 0.5rem; - - p { - margin: 0.5rem 0; - } - - code { - background: var(--tui-base-01); - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-family: monospace; - } -} - .import-form { margin-top: 2rem; } From 3f8de6614867cc56b0f7c1138305d1ad9e3ede7e Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 22:09:11 +0300 Subject: [PATCH 111/156] feat: Improve formatting in operations list Date Format: - Change from locale format to YYYY.MM.DD, HH:MM:SS - Example: 2024.01.15, 14:30:45 - More compact and sortable format - Consistent with international standard Currency Format: - Add space as thousand separator for better readability - Example: 1 234 567.89 USD instead of 1234567.89 USD - Uses regex to insert spaces: /\\B(?=(\\d{3})+(?!\\d))/g - Keeps decimal precision at 2 digits Description Column: - Increase min-width from 200px to 300px - Add max-width of 600px - Better balance between readability and table width - More space for longer operation descriptions Benefits: - Easier to read large amounts - Consistent date format across locales - More readable descriptions - Professional financial formatting --- .../operations-list.component.less | 3 ++- .../operations-list.component.ts | 20 +++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 32fdebf0..1ee766d2 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -98,7 +98,8 @@ &.col-description { width: auto; - min-width: 200px; + min-width: 300px; + max-width: 600px; } &.col-amount { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 5dd6898e..ef21c120 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -108,20 +108,20 @@ export class OperationsListComponent implements OnInit { } formatCurrency(amount: number, currencyCode: string): string { - return `${amount.toFixed(2)} ${currencyCode}`; + // Format number with space as thousand separator + const formattedAmount = amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + return `${formattedAmount} ${currencyCode}`; } formatDate(timestamp: string): string { const date = new Date(timestamp); - return date.toLocaleString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false - }); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}.${month}.${day}, ${hours}:${minutes}:${seconds}`; } navigateToImport(): void { From 5f1de0e5cf16e4761939862e9044985af00baf82 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sat, 18 Oct 2025 22:12:18 +0300 Subject: [PATCH 112/156] fix: Enforce single-line display for amount column Add white-space: nowrap to amount column (both th and td) to prevent amounts from wrapping to multiple lines. Changes: - Add white-space: nowrap to th.col-amount - Add white-space: nowrap to td.col-amount Benefits: - Amounts always display on one line - Consistent table row heights - Better readability - Professional appearance - No broken formatting for large amounts with thousand separators Example: Before (potential): 1 234 567 .89 USD After (guaranteed): 1 234 567.89 USD --- .../operations/operations-list/operations-list.component.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 1ee766d2..65db3b35 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -105,6 +105,7 @@ &.col-amount { width: 150px; text-align: right; + white-space: nowrap; } &.col-tags { @@ -154,6 +155,7 @@ text-align: right; font-weight: 700; font-family: monospace; + white-space: nowrap; &.positive { color: var(--tui-success-fill); From 4f70fcaacb05296cb937cae59b6f10177810f8de Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 19 Oct 2025 13:22:48 +0300 Subject: [PATCH 113/156] chore: removed max-width limits --- .../budget-detail/budget-detail.component.less | 3 +-- .../reading-settings/reading-settings.component.less | 3 +-- .../budget-client/src/app/index/index.component.less | 6 ++---- .../delete-operations.component.less | 3 +-- .../import-operations.component.less | 3 +-- .../operations-list/operations-list.component.less | 12 ++++-------- 6 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less index cda082e6..4291488f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/budget-detail/budget-detail.component.less @@ -1,6 +1,5 @@ .budget-detail-container { - max-width: 1200px; - margin: 0 auto; + margin: 0 auto; padding: 2rem; } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less index 8346ec69..81467d59 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/reading-settings/reading-settings.component.less @@ -1,7 +1,6 @@ .reading-settings-container { padding: 2rem; - max-width: 1200px; - margin: 0 auto; + margin: 0 auto; } .settings-card { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less index 19ac68d1..59f57d58 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/index/index.component.less @@ -1,6 +1,5 @@ .index-container { - max-width: 1400px; - margin: 0 auto; + margin: 0 auto; padding: 2rem; } @@ -82,8 +81,7 @@ .welcome-card { text-align: center; padding: 3rem; - max-width: 600px; - margin: 2rem auto; + margin: 2rem auto; h2 { margin-bottom: 1rem; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less index 6c1d08d1..842277a6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less @@ -1,7 +1,6 @@ .delete-container { padding: 2rem; - max-width: 900px; - margin: 0 auto; + margin: 0 auto; } .delete-card { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less index 122f032c..a3c5083b 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less @@ -1,7 +1,6 @@ .import-container { padding: 2rem; - max-width: 900px; - margin: 0 auto; + margin: 0 auto; } .import-card { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 65db3b35..5ac29362 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -1,7 +1,6 @@ .operations-container { padding: 2rem; - max-width: 1200px; - margin: 0 auto; + margin: 0 auto; } .operations-card { @@ -99,8 +98,7 @@ &.col-description { width: auto; min-width: 300px; - max-width: 600px; - } + } &.col-amount { width: 150px; @@ -167,8 +165,7 @@ } &.col-tags { - max-width: 150px; - overflow: hidden; + overflow: hidden; .tags-inline { display: flex; @@ -189,8 +186,7 @@ } &.col-attributes { - max-width: 250px; - overflow: hidden; + overflow: hidden; .attributes-inline { display: flex; From 5df9e496e852326a526d42c4adbfb556191e48f7 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 19 Oct 2025 13:27:56 +0300 Subject: [PATCH 114/156] refactor: Extract reusable operations-table component Create a new reusable OperationsTableComponent and use it in both operations-list and import-operations (for duplicates). New Component (operations-table): - Standalone component with operations array input - showActions input to toggle actions column visibility - Displays operations in consistent table format - Supports expand/collapse for details - Includes all formatting logic (dates, currency, etc.) - Excel-like table styling with borders Changes to operations-list: - Replace inline table HTML with - Remove formatDate(), formatCurrency(), getObjectKeys() methods - Remove expandedOperationId property - Add operations array property to pass to table component - Subscribe to operations\$ to update array - Remove table-related CSS (moved to component) Changes to import-operations: - Replace simple duplicate display with - Remove formatOperationSummary() method - Remove TuiChip dependency (now in table component) - Remove duplicate-operation CSS (now using table) - Set showActions=false for duplicate tables Benefits: - Consistent operations display across all screens - Single source of truth for formatting - DRY - no duplicate table HTML/CSS - Easier to maintain and update - Duplicates now show full operation details - Better UX with expandable rows in duplicates --- .../import-operations.component.html | 13 +- .../import-operations.component.ts | 4 +- .../operations-list.component.html | 83 +------ .../operations-list.component.ts | 35 +-- .../operations-table.component.html | 83 +++++++ .../operations-table.component.less | 214 ++++++++++++++++++ .../operations-table.component.ts | 50 ++++ 7 files changed, 361 insertions(+), 121 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html index 21237b82..3a94453b 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html @@ -129,18 +129,7 @@

Duplicates ({{ importResult.duplicates }} groups)

@for (group of getDuplicatesList(); track $index; let groupIdx = $index) {
Duplicate Group {{ groupIdx + 1 }} ({{ group.length }} operations)
-
- @for (op of group; track op.id) { -
-
{{ formatOperationSummary(op) }}
-
- @for (tag of op.tags; track tag) { - {{ tag }} - } -
-
- } -
+
} } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts index ed70aecb..bcbecaac 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts @@ -14,7 +14,7 @@ import { TuiLabel } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiChip } from '@taiga-ui/kit'; +import { OperationsTableComponent } from '../operations-table/operations-table.component'; @Component({ selector: 'app-import-operations', @@ -28,7 +28,7 @@ import { TuiChip } from '@taiga-ui/kit'; TuiLabel, TuiCardLarge, TuiTitle, - TuiChip + OperationsTableComponent ], templateUrl: './import-operations.component.html', styleUrls: ['./import-operations.component.less'] diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 78c18896..19f0cc2e 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -93,86 +93,9 @@

Filters

- @if (operations$ | async; as operations) { - @if (operations.length > 0) { -
- - - - - - - - - - - - - @for (operation of operations; track operation.id) { - - - - - - - - - - @if (expandedOperationId === operation.id) { - - - - } - } - -
Date & TimeDescriptionAmountTagsAttributesActions
{{ formatDate(operation.timestamp) }}{{ operation.description }} - {{ formatCurrency(operation.amount.value, operation.amount.currencyCode) }} - -
- @for (tag of operation.tags; track tag) { - {{ tag }} - } -
-
- @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { -
- @for (key of getObjectKeys(operation.attributes!); track key) { - - {{ key }}: {{ operation.attributes![key] }} - - } -
- } @else { - - } -
- -
-
-
-
- Operation ID: - {{ operation.id }} -
-
- Version: - {{ operation.version }} -
-
- Budget ID: - {{ operation.budgetId }} -
-
-
-
-
+ @if (operations$ | async; as ops) { + @if (ops.length > 0) { +
Total: {{ operations.length }} operations diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index ef21c120..7f71af8c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -15,7 +15,8 @@ import { TuiExpand } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiChip, TuiAccordion, TuiTextarea, TuiCheckbox } from '@taiga-ui/kit'; +import { TuiAccordion, TuiTextarea, TuiCheckbox } from '@taiga-ui/kit'; +import { OperationsTableComponent } from '../operations-table/operations-table.component'; @Component({ selector: 'app-operations-list', @@ -29,11 +30,11 @@ import { TuiChip, TuiAccordion, TuiTextarea, TuiCheckbox } from '@taiga-ui/kit'; TuiLabel, TuiCardLarge, TuiTitle, - TuiChip, TuiAccordion, TuiExpand, TuiTextarea, - TuiCheckbox + TuiCheckbox, + OperationsTableComponent ], templateUrl: './operations-list.component.html', styleUrls: ['./operations-list.component.less'] @@ -41,12 +42,10 @@ import { TuiChip, TuiAccordion, TuiTextarea, TuiCheckbox } from '@taiga-ui/kit'; export class OperationsListComponent implements OnInit { budgetId!: string; operations$!: Observable; + operations: OperationResponse[] = []; isLoading = false; filterForm!: FormGroup; - expandedOperationId: string | null = null; - - readonly columns = ['timestamp', 'description', 'amount', 'tags', 'actions']; constructor( private route: ActivatedRoute, @@ -81,6 +80,9 @@ export class OperationsListComponent implements OnInit { return of([]); }) ); + + // Subscribe to update the array for the table component + this.operations$.subscribe(ops => this.operations = ops); } applyFilters(): void { @@ -103,27 +105,6 @@ export class OperationsListComponent implements OnInit { } } - toggleOperationDetails(operationId: string): void { - this.expandedOperationId = this.expandedOperationId === operationId ? null : operationId; - } - - formatCurrency(amount: number, currencyCode: string): string { - // Format number with space as thousand separator - const formattedAmount = amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); - return `${formattedAmount} ${currencyCode}`; - } - - formatDate(timestamp: string): string { - const date = new Date(timestamp); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${year}.${month}.${day}, ${hours}:${minutes}:${seconds}`; - } - navigateToImport(): void { this.router.navigate(['/budget', this.budgetId, 'operations', 'import']); } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html new file mode 100644 index 00000000..17b1df54 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html @@ -0,0 +1,83 @@ +
+ + + + + + + + + @if (showActions) { + + } + + + + @for (operation of operations; track operation.id) { + + + + + + + @if (showActions) { + + } + + + @if (expandedOperationId === operation.id) { + + + + } + } + +
Date & TimeDescriptionAmountTagsAttributesActions
{{ formatDate(operation.timestamp) }}{{ operation.description }} + {{ formatCurrency(operation.amount.value, operation.amount.currencyCode) }} + +
+ @for (tag of operation.tags; track tag) { + {{ tag }} + } +
+
+ @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { +
+ @for (key of getObjectKeys(operation.attributes!); track key) { + + {{ key }}: {{ operation.attributes![key] }} + + } +
+ } @else { + + } +
+ +
+ +
+
+ Operation ID: + {{ operation.id }} +
+
+ Budget ID: + {{ operation.budgetId }} +
+
+ Version: + {{ operation.version }} +
+
+
+
+
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less new file mode 100644 index 00000000..7d7caefd --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less @@ -0,0 +1,214 @@ +.operations-table-container { + overflow-x: auto; + border-radius: 0.5rem; + border: 1px solid var(--tui-base-04); +} + +.operations-table { + width: 100%; + border-collapse: collapse; + background: var(--tui-base-01); + + thead { + background: var(--tui-base-02); + position: sticky; + top: 0; + z-index: 1; + + tr { + border-bottom: 2px solid var(--tui-base-04); + } + + th { + padding: 0.75rem 1rem; + text-align: left; + font-weight: 600; + font-size: 0.875rem; + color: var(--tui-text-02); + border-right: 1px solid var(--tui-base-04); + + &:last-child { + border-right: none; + } + + &.col-timestamp { + width: 180px; + } + + &.col-description { + width: auto; + min-width: 300px; + } + + &.col-amount { + width: 150px; + text-align: right; + white-space: nowrap; + } + + &.col-tags { + width: 150px; + } + + &.col-attributes { + width: 250px; + } + + &.col-actions { + width: 60px; + text-align: center; + } + } + } + + tbody { + tr.operation-row { + border-bottom: 1px solid var(--tui-base-04); + transition: background-color 0.15s; + + &:hover { + background: var(--tui-base-02); + } + + td { + padding: 0.75rem 1rem; + vertical-align: top; + border-right: 1px solid var(--tui-base-04); + + &:last-child { + border-right: none; + } + + &.col-timestamp { + font-family: monospace; + font-size: 0.875rem; + color: var(--tui-text-02); + } + + &.col-description { + color: var(--tui-text-01); + } + + &.col-amount { + text-align: right; + font-weight: 700; + font-family: monospace; + white-space: nowrap; + + &.positive { + color: var(--tui-success-fill); + } + + &.negative { + color: var(--tui-error-fill); + } + } + + &.col-tags { + overflow: hidden; + + .tags-inline { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + max-height: 60px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--tui-base-05); + border-radius: 2px; + } + } + } + + &.col-attributes { + overflow: hidden; + + .attributes-inline { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + max-height: 60px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--tui-base-05); + border-radius: 2px; + } + } + + .attr-chip { + font-size: 0.75rem; + } + + .no-attributes { + color: var(--tui-text-03); + font-style: italic; + } + } + + &.col-actions { + text-align: center; + } + } + } + + tr.operation-details-row { + background: var(--tui-base-02); + + td { + padding: 0; + border-bottom: 1px solid var(--tui-base-04); + } + } + } +} + +.operation-details { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.detail-row { + display: flex; + gap: 1rem; + + .detail-label { + font-weight: 600; + min-width: 120px; + color: var(--tui-text-02); + } + + .detail-value { + color: var(--tui-text-01); + + code { + padding: 0.125rem 0.375rem; + background: var(--tui-base-03); + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.875rem; + } + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts new file mode 100644 index 00000000..4b9b5d32 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts @@ -0,0 +1,50 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { OperationResponse } from '../../budget/models'; +import { TuiButton, TuiExpand } from '@taiga-ui/core'; +import { TuiChip } from '@taiga-ui/kit'; + +@Component({ + selector: 'app-operations-table', + standalone: true, + imports: [ + CommonModule, + TuiButton, + TuiExpand, + TuiChip + ], + templateUrl: './operations-table.component.html', + styleUrls: ['./operations-table.component.less'] +}) +export class OperationsTableComponent { + @Input() operations: OperationResponse[] = []; + @Input() showActions = true; + + expandedOperationId: string | null = null; + + toggleOperationDetails(operationId: string): void { + this.expandedOperationId = this.expandedOperationId === operationId ? null : operationId; + } + + formatCurrency(amount: number, currencyCode: string): string { + // Format number with space as thousand separator + const formattedAmount = amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + return `${formattedAmount} ${currencyCode}`; + } + + formatDate(timestamp: string): string { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}.${month}.${day}, ${hours}:${minutes}:${seconds}`; + } + + getObjectKeys(obj: any): string[] { + return obj ? Object.keys(obj) : []; + } +} + From 7ccd82473c64a40b98548017a3ae5d1e1cd6cb73 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 19 Oct 2025 13:28:31 +0300 Subject: [PATCH 115/156] cleanup: Remove unused duplicate display CSS Remove obsolete CSS for simple duplicate display now that we use the operations-table component for duplicates. Removed: - .duplicate-operations styles - .duplicate-operation styles - .op-summary styles - .op-tags styles Updated: - .duplicate-group to only style the header - Header now has warning background with border The operations-table component handles all styling for the duplicate operation rows. --- .../import-operations.component.less | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less index a3c5083b..33d0db2c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less @@ -113,39 +113,15 @@ .duplicate-group { margin-bottom: 1.5rem; - padding: 1rem; - background: var(--tui-warning-bg); - border-left: 3px solid var(--tui-warning-fill); - border-radius: 0.25rem; h5 { - margin: 0 0 1rem 0; + margin: 0 0 0.75rem 0; color: var(--tui-text-01); font-size: 1rem; - } -} - -.duplicate-operations { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.duplicate-operation { - padding: 0.75rem; - background: var(--tui-base-01); - border-radius: 0.25rem; - - .op-summary { - margin-bottom: 0.5rem; - font-size: 0.875rem; - color: var(--tui-text-01); - } - - .op-tags { - display: flex; - gap: 0.25rem; - flex-wrap: wrap; + padding: 0.75rem 1rem; + background: var(--tui-warning-bg); + border-left: 3px solid var(--tui-warning-fill); + border-radius: 0.25rem 0.25rem 0 0; } } From 923920beab92a2bd673f59791cd15c0fbed68e0a Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 19 Oct 2025 13:28:57 +0300 Subject: [PATCH 116/156] cleanup: Remove unused formatOperationSummary method Remove formatOperationSummary() method from import-operations component as it's no longer needed after switching to operations-table component for displaying duplicates. --- .../import-operations/import-operations.component.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts index bcbecaac..4962c753 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts @@ -196,10 +196,6 @@ export class ImportOperationsComponent implements OnInit { this.showDuplicates = !this.showDuplicates; } - formatOperationSummary(op: any): string { - return `${new Date(op.timestamp).toLocaleDateString()} - ${op.description} - ${op.amount.value} ${op.amount.currencyCode}`; - } - getDuplicatesList(): any[] { return (this.importResult as any)?.duplicatesList || []; } From 467fc68c92713dfd90af771490af78b952a72057 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 19 Oct 2025 13:39:29 +0300 Subject: [PATCH 117/156] feat: Add delete action button to operations table --- .../operations-list.component.html | 6 +++- .../operations-list.component.ts | 29 +++++++++++++++++++ .../operations-table.component.html | 27 ++++++++++++----- .../operations-table.component.less | 9 +++++- .../operations-table.component.ts | 7 ++++- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 19f0cc2e..32c89763 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -95,7 +95,11 @@

Filters

@if (operations$ | async; as ops) { @if (ops.length > 0) { - + +
Total: {{ operations.length }} operations diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 7f71af8c..287cd997 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -117,6 +117,35 @@ export class OperationsListComponent implements OnInit { this.router.navigate(['/budget', this.budgetId, 'operations', 'delete']); } + onDeleteOperation(operation: OperationResponse): void { + const confirmMessage = `Are you sure you want to delete this operation?\n\n${operation.description}\n${operation.amount.value} ${operation.amount.currencyCode}\n\nThis action cannot be undone.`; + + if (!confirm(confirmMessage)) { + return; + } + + this.isLoading = true; + const criteria = `o => o.Id == Guid.Parse("${operation.id}")`; + + this.operationsApi.removeOperations(this.budgetId, { criteria }).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.showError(`Failed to delete operation: ${errorMessage}`); + } else { + this.showSuccess('Operation deleted successfully'); + this.loadOperations(); + } + }, + error: (error) => { + this.isLoading = false; + this.handleError(error, 'Failed to delete operation'); + } + }); + } + private handleError(error: any, defaultMessage: string): void { let errorMessage = defaultMessage; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html index 17b1df54..2d17d9a8 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html @@ -42,14 +42,25 @@ @if (showActions) { - +
+ + +
} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less index 7d7caefd..2eaacf10 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less @@ -55,7 +55,7 @@ } &.col-actions { - width: 60px; + width: 100px; text-align: center; } } @@ -167,6 +167,13 @@ &.col-actions { text-align: center; + + .actions-buttons { + display: flex; + gap: 0.25rem; + justify-content: center; + align-items: center; + } } } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts index 4b9b5d32..579d55d8 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, Output, EventEmitter } from '@angular/core'; import { CommonModule } from '@angular/common'; import { OperationResponse } from '../../budget/models'; import { TuiButton, TuiExpand } from '@taiga-ui/core'; @@ -19,6 +19,7 @@ import { TuiChip } from '@taiga-ui/kit'; export class OperationsTableComponent { @Input() operations: OperationResponse[] = []; @Input() showActions = true; + @Output() operationDeleted = new EventEmitter(); expandedOperationId: string | null = null; @@ -46,5 +47,9 @@ export class OperationsTableComponent { getObjectKeys(obj: any): string[] { return obj ? Object.keys(obj) : []; } + + deleteOperation(operation: OperationResponse): void { + this.operationDeleted.emit(operation); + } } From 4d102ea2f47ee503eea2d42d2b144eb65a15223d Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 19 Oct 2025 13:53:05 +0300 Subject: [PATCH 118/156] feat: Add inline edit functionality to operations table --- .../operations-list.component.html | 3 +- .../operations-list.component.ts | 49 ++++- .../operations-table.component.html | 181 +++++++++++++++--- .../operations-table.component.less | 75 +++++++- .../operations-table.component.ts | 96 +++++++++- 5 files changed, 370 insertions(+), 34 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 32c89763..cfc83a03 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -98,7 +98,8 @@

Filters

+ (operationDeleted)="onDeleteOperation($event)" + (operationUpdated)="onUpdateOperation($event)">
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 287cd997..b0822d6c 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -4,7 +4,8 @@ import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable, catchError, of } from 'rxjs'; import { OperationsApiService } from '../operations-api.service'; -import { OperationResponse } from '../../budget/models'; +import { BudgetApiService } from '../../budget/budget-api.service'; +import { OperationResponse, BudgetResponse } from '../../budget/models'; import { TuiButton, TuiDialogService, @@ -51,6 +52,7 @@ export class OperationsListComponent implements OnInit { private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, + private budgetApi: BudgetApiService, private fb: FormBuilder, private dialogService: TuiDialogService ) {} @@ -146,6 +148,51 @@ export class OperationsListComponent implements OnInit { }); } + onUpdateOperation(operation: OperationResponse): void { + this.isLoading = true; + + // Get the budget to access its version + this.budgetApi.getAllBudgets().subscribe({ + next: (budgetList: BudgetResponse[]) => { + const budget = budgetList.find((b: BudgetResponse) => b.id === this.budgetId); + if (!budget) { + this.isLoading = false; + this.showError('Budget not found'); + return; + } + + const request = { + operations: [operation], + budgetVersion: budget.version, + transferConfidenceLevel: undefined, + taggingMode: 'PreserveExistingTags' + }; + + this.operationsApi.updateOperations(this.budgetId, request).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.showError(`Failed to update operation: ${errorMessage}`); + } else { + this.showSuccess('Operation updated successfully'); + this.loadOperations(); + } + }, + error: (error: any) => { + this.isLoading = false; + this.handleError(error, 'Failed to update operation'); + } + }); + }, + error: (error: any) => { + this.isLoading = false; + this.handleError(error, 'Failed to load budget'); + } + }); + } + private handleError(error: any, defaultMessage: string): void { let errorMessage = defaultMessage; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html index 2d17d9a8..3d9e493f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html @@ -14,53 +14,176 @@ @for (operation of operations; track operation.id) { - + {{ formatDate(operation.timestamp) }} - {{ operation.description }} - - {{ formatCurrency(operation.amount.value, operation.amount.currencyCode) }} - - -
- @for (tag of operation.tags; track tag) { - {{ tag }} - } -
+ + @if (isEditing(operation.id) && editingOperation) { + + } @else { + {{ operation.description }} + } - - @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { -
- @for (key of getObjectKeys(operation.attributes!); track key) { - - {{ key }}: {{ operation.attributes![key] }} - - } + + @if (isEditing(operation.id) && editingOperation) { +
+ +
} @else { - + {{ formatCurrency(operation.amount.value, operation.amount.currencyCode) }} } - @if (showActions) { - -
+ + @if (isEditing(operation.id) && editingOperation) { +
+ @for (tag of editingOperation.tags; track trackByIndex($index); let i = $index) { +
+ + +
+ } +
+ } @else { +
+ @for (tag of operation.tags; track tag) { + {{ tag }} + } +
+ } + + + @if (isEditing(operation.id) && editingOperation) { +
+ @for (key of getObjectKeys(editingOperation.attributes); track key) { +
+ + + +
+ }
+ } @else { + @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { +
+ @for (key of getObjectKeys(operation.attributes!); track key) { + + {{ key }}: {{ operation.attributes![key] }} + + } +
+ } @else { + + } + } + + @if (showActions) { + + @if (isEditing(operation.id)) { +
+ + +
+ } @else { +
+ + + +
+ } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less index 2eaacf10..42bc35cb 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less @@ -55,7 +55,7 @@ } &.col-actions { - width: 100px; + width: 130px; text-align: center; } } @@ -70,6 +70,14 @@ background: var(--tui-base-02); } + &.editing { + background: var(--tui-primary-bg); + + &:hover { + background: var(--tui-primary-bg); + } + } + td { padding: 0.75rem 1rem; vertical-align: top; @@ -173,6 +181,10 @@ gap: 0.25rem; justify-content: center; align-items: center; + + &.edit-mode { + gap: 0.5rem; + } } } } @@ -219,3 +231,64 @@ } } +// Edit mode styles +.edit-input { + width: 100%; + padding: 0.375rem 0.5rem; + border: 1px solid var(--tui-base-04); + border-radius: 0.25rem; + background: var(--tui-base-01); + color: var(--tui-text-01); + font-size: 0.875rem; + font-family: inherit; + + &:focus { + outline: none; + border-color: var(--tui-primary); + box-shadow: 0 0 0 2px var(--tui-primary-hover); + } +} + +.amount-edit { + display: flex; + gap: 0.5rem; + align-items: center; + + .amount-input { + flex: 1; + min-width: 80px; + } + + .currency-input { + width: 50px; + text-align: center; + text-transform: uppercase; + } +} + +.tags-edit, +.attributes-edit { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tag-edit-item, +.attribute-edit-item { + display: flex; + gap: 0.25rem; + align-items: center; + + .tag-input { + flex: 1; + } + + .attr-key-input { + flex: 0 0 80px; + } + + .attr-value-input { + flex: 1; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts index 579d55d8..5382994a 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts @@ -1,17 +1,29 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; import { OperationResponse } from '../../budget/models'; -import { TuiButton, TuiExpand } from '@taiga-ui/core'; +import { TuiButton, TuiExpand, TuiTextfield } from '@taiga-ui/core'; import { TuiChip } from '@taiga-ui/kit'; +interface EditableOperation { + id: string; + description: string; + amount: number; + currencyCode: string; + tags: string[]; + attributes: Record; +} + @Component({ selector: 'app-operations-table', standalone: true, imports: [ CommonModule, + FormsModule, TuiButton, TuiExpand, - TuiChip + TuiChip, + TuiTextfield ], templateUrl: './operations-table.component.html', styleUrls: ['./operations-table.component.less'] @@ -20,8 +32,11 @@ export class OperationsTableComponent { @Input() operations: OperationResponse[] = []; @Input() showActions = true; @Output() operationDeleted = new EventEmitter(); + @Output() operationUpdated = new EventEmitter(); expandedOperationId: string | null = null; + editingOperationId: string | null = null; + editingOperation: EditableOperation | null = null; toggleOperationDetails(operationId: string): void { this.expandedOperationId = this.expandedOperationId === operationId ? null : operationId; @@ -51,5 +66,82 @@ export class OperationsTableComponent { deleteOperation(operation: OperationResponse): void { this.operationDeleted.emit(operation); } + + startEdit(operation: OperationResponse): void { + this.editingOperationId = operation.id; + this.editingOperation = { + id: operation.id, + description: operation.description, + amount: operation.amount.value, + currencyCode: operation.amount.currencyCode, + tags: [...operation.tags], + attributes: { ...operation.attributes || {} } + }; + } + + cancelEdit(): void { + this.editingOperationId = null; + this.editingOperation = null; + } + + saveEdit(operation: OperationResponse): void { + if (!this.editingOperation) return; + + const updatedOperation: OperationResponse = { + ...operation, + description: this.editingOperation.description, + amount: { + value: this.editingOperation.amount, + currencyCode: this.editingOperation.currencyCode + }, + tags: this.editingOperation.tags, + attributes: this.editingOperation.attributes + }; + + this.operationUpdated.emit(updatedOperation); + this.editingOperationId = null; + this.editingOperation = null; + } + + isEditing(operationId: string): boolean { + return this.editingOperationId === operationId; + } + + addTag(): void { + if (this.editingOperation) { + this.editingOperation.tags.push(''); + } + } + + removeTag(index: number): void { + if (this.editingOperation) { + this.editingOperation.tags.splice(index, 1); + } + } + + trackByIndex(index: number): number { + return index; + } + + addAttribute(): void { + if (this.editingOperation) { + const key = `key${Object.keys(this.editingOperation.attributes).length + 1}`; + this.editingOperation.attributes[key] = ''; + } + } + + removeAttribute(key: string): void { + if (this.editingOperation) { + delete this.editingOperation.attributes[key]; + } + } + + updateAttributeKey(oldKey: string, newKey: string): void { + if (this.editingOperation && oldKey !== newKey && newKey) { + const value = this.editingOperation.attributes[oldKey]; + delete this.editingOperation.attributes[oldKey]; + this.editingOperation.attributes[newKey] = value; + } + } } From d601ec6e324fa397972d164ea7f8bd19e451f7f1 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 19 Oct 2025 16:50:17 +0300 Subject: [PATCH 119/156] fix: fixed update behavior --- .../app/operations/operations-list/operations-list.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index b0822d6c..178595f1 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -165,7 +165,7 @@ export class OperationsListComponent implements OnInit { operations: [operation], budgetVersion: budget.version, transferConfidenceLevel: undefined, - taggingMode: 'PreserveExistingTags' + taggingMode: 'Skip' }; this.operationsApi.updateOperations(this.budgetId, request).subscribe({ From f404ef8df50190bd04f415e257da4bbc063836ec Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Sun, 19 Oct 2025 16:57:27 +0300 Subject: [PATCH 120/156] feat: Add duplicates list screen --- .../Controllers/OperationsController.cs | 55 ++++++- .../budget-client/src/app/app.routes.ts | 2 + .../duplicates-list.component.html | 132 +++++++++++++++++ .../duplicates-list.component.less | 137 ++++++++++++++++++ .../duplicates-list.component.ts | 109 ++++++++++++++ .../app/operations/operations-api.service.ts | 14 ++ .../operations-list.component.html | 8 + .../operations-list.component.ts | 4 + 8 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index 4b9bd10f..b0e3e3da 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -12,6 +12,7 @@ using NVs.Budget.Application.Contracts.Queries; using NVs.Budget.Application.Contracts.UseCases.Budgets; using NVs.Budget.Application.Contracts.UseCases.Operations; +using System.Linq; using NVs.Budget.Controllers.Web.Exceptions; using NVs.Budget.Controllers.Web.Models; using NVs.Budget.Controllers.Web.Utils; @@ -40,11 +41,55 @@ public class OperationsController( /// Whether to exclude transfers from results /// Cancellation token /// Collection of operations - [HttpGet] - [ProducesResponseType(typeof(IAsyncEnumerable), 200)] - [ProducesResponseType(typeof(IEnumerable), 400)] - [ProducesResponseType(typeof(IEnumerable), 404)] - public async IAsyncEnumerable GetOperations( + [HttpGet("duplicates")] + [ProducesResponseType(typeof(IReadOnlyCollection>), 200)] + [ProducesResponseType(typeof(IEnumerable), 400)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async Task GetDuplicates( + [FromRoute] Guid budgetId, + [FromQuery] string? criteria = null, + CancellationToken ct = default) + { + var budgets = await mediator.Send(new ListOwnedBudgetsQuery(), ct); + var budget = budgets.FirstOrDefault(b => b.Id == budgetId); + + if (budget == null) + { + throw new NotFoundException($"Budget with ID {budgetId} not found or access denied"); + } + + Expression>? conditions = null; + + if (!string.IsNullOrWhiteSpace(criteria)) + { + var criteriaResult = parser.ParseUnaryPredicate(criteria); + if (criteriaResult.IsFailed) + { + throw new BadRequestException($"Invalid criteria: {string.Join("; ", criteriaResult.Errors.Select(e => e.Message))}"); + } + conditions = criteriaResult.Value.AsExpression(); + } + else + { + // Default: all operations + conditions = o => true; + } + + var query = new ListDuplicatedOperationsQuery(conditions); + var duplicateGroups = await mediator.Send(query, ct); + + var response = duplicateGroups + .Select(group => group.Select(mapper.ToResponse).ToList()) + .ToList(); + + return Ok(response); + } + + [HttpGet] + [ProducesResponseType(typeof(IAsyncEnumerable), 200)] + [ProducesResponseType(typeof(IEnumerable), 400)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async IAsyncEnumerable GetOperations( [FromRoute] Guid budgetId, [FromQuery] string? criteria = null, [FromQuery] string? outputCurrency = null, diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts index 0e6337c2..d86d98fe 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts @@ -5,12 +5,14 @@ import { ReadingSettingsComponent } from './budget/reading-settings/reading-sett import { ImportOperationsComponent } from './operations/import-operations/import-operations.component'; import { OperationsListComponent } from './operations/operations-list/operations-list.component'; import { DeleteOperationsComponent } from './operations/delete-operations/delete-operations.component'; +import { DuplicatesListComponent } from './operations/duplicates-list/duplicates-list.component'; import { IndexComponent } from './index/index.component'; export const routes: Routes = [ { path: 'budget/new', component: NewBudgetComponent }, { path: 'budget/:budgetId/operations/import', component: ImportOperationsComponent }, { path: 'budget/:budgetId/operations/delete', component: DeleteOperationsComponent }, + { path: 'budget/:budgetId/operations/duplicates', component: DuplicatesListComponent }, { path: 'budget/:budgetId/operations', component: OperationsListComponent }, { path: 'budget/:budgetId/reading-settings', component: ReadingSettingsComponent }, { path: 'budget/:budgetId', component: BudgetDetailComponent }, diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html new file mode 100644 index 00000000..73d2729d --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html @@ -0,0 +1,132 @@ +
+ +
+
+

Duplicate Operations

+
+ + +
+
+ + + +

Filter Criteria

+
+
+ + + + +
+
+ +
+
+

Common Examples

+ +
+ + @if (showExamples) { +
+
+ All operations: + o => true +
+
+ Specific year: + o => o.Timestamp.Year == 2024 +
+
+ Negative amounts only: + o => o.Amount.Amount < 0 +
+
+ By description contains: + o => o.Description.Contains("coffee") +
+
+ Recent operations: + o => o.Timestamp > DateTime.Now.AddDays(-30) +
+
+ } +
+ +
+ + +
+ + + +
+ @if (duplicateGroups.length > 0) { +
+

Found {{ duplicateGroups.length }} duplicate groups ({{ getTotalDuplicates() }} operations)

+
+ + @for (group of duplicateGroups; track $index; let groupIdx = $index) { +
+

Duplicate Group {{ groupIdx + 1 }} ({{ group.length }} operations)

+ +
+ } + } @else { +
+

No duplicate operations found with the current criteria.

+ +
+ } +
+
+
+
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.less new file mode 100644 index 00000000..51c8fabf --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.less @@ -0,0 +1,137 @@ +.duplicates-container { + padding: 2rem; + margin: 0 auto; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.header-actions { + display: flex; + gap: 1rem; +} + +.filters-section { + margin-bottom: 1.5rem; + padding: 1.5rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + + h3 { + margin-bottom: 1rem; + } +} + +.filters-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-bottom: 1rem; + + .filter-item { + &.full-width { + grid-column: 1 / -1; + } + } +} + +.examples-section { + margin: 1rem 0; + padding: 1rem; + background: var(--tui-base-01); + border-radius: 0.25rem; +} + +.examples-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + + h4 { + margin: 0; + color: var(--tui-text-02); + } +} + +.examples-list { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.example-item { + padding: 0.75rem; + background: var(--tui-base-02); + border-radius: 0.25rem; + + strong { + display: block; + margin-bottom: 0.25rem; + color: var(--tui-text-02); + font-size: 0.875rem; + } + + code { + display: block; + background: var(--tui-base-03); + padding: 0.5rem; + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.875rem; + color: var(--tui-text-01); + } +} + +.filter-actions { + display: flex; + gap: 1rem; +} + +.duplicates-section { + margin-top: 2rem; +} + +.duplicates-summary { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--tui-warning-bg); + border-left: 3px solid var(--tui-warning-fill); + border-radius: 0.25rem; + + h3 { + margin: 0; + color: var(--tui-text-01); + } +} + +.duplicate-group { + margin-bottom: 2rem; + + h4 { + margin: 0 0 0.75rem 0; + color: var(--tui-text-01); + font-size: 1rem; + padding: 0.75rem 1rem; + background: var(--tui-warning-bg); + border-left: 3px solid var(--tui-warning-fill); + border-radius: 0.25rem 0.25rem 0 0; + } +} + +.empty-state { + padding: 3rem; + text-align: center; + color: var(--tui-text-02); + + p { + margin-bottom: 1rem; + font-size: 1.125rem; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts new file mode 100644 index 00000000..09a33279 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts @@ -0,0 +1,109 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { OperationsApiService } from '../operations-api.service'; +import { OperationResponse } from '../../budget/models'; +import { + TuiButton, + TuiLoader, + TuiTitle, + TuiTextfield, + TuiLabel +} from '@taiga-ui/core'; +import { TuiCardLarge } from '@taiga-ui/layout'; +import { TuiTextarea } from '@taiga-ui/kit'; +import { OperationsTableComponent } from '../operations-table/operations-table.component'; + +@Component({ + selector: 'app-duplicates-list', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TuiButton, + TuiLoader, + TuiTextfield, + TuiLabel, + TuiCardLarge, + TuiTitle, + TuiTextarea, + OperationsTableComponent + ], + templateUrl: './duplicates-list.component.html', + styleUrls: ['./duplicates-list.component.less'] +}) +export class DuplicatesListComponent implements OnInit { + budgetId!: string; + duplicateGroups: OperationResponse[][] = []; + isLoading = false; + + filterForm!: FormGroup; + showExamples = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private operationsApi: OperationsApiService, + private fb: FormBuilder + ) {} + + ngOnInit(): void { + this.budgetId = this.route.snapshot.params['budgetId']; + + this.filterForm = this.fb.group({ + criteria: ['o => true'] + }); + + this.loadDuplicates(); + } + + loadDuplicates(): void { + this.isLoading = true; + const criteria = this.filterForm.value.criteria || undefined; + + this.operationsApi.getDuplicates(this.budgetId, criteria).subscribe({ + next: (groups) => { + this.duplicateGroups = groups; + this.isLoading = false; + }, + error: (error) => { + console.error('Failed to load duplicates', error); + this.isLoading = false; + } + }); + } + + applyFilters(): void { + this.loadDuplicates(); + } + + clearFilters(): void { + this.filterForm.patchValue({ criteria: 'o => true' }); + this.loadDuplicates(); + } + + toggleExamples(): void { + this.showExamples = !this.showExamples; + } + + onCriteriaKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' && event.ctrlKey) { + event.preventDefault(); + this.applyFilters(); + } + } + + navigateToOperations(): void { + this.router.navigate(['/budget', this.budgetId, 'operations']); + } + + navigateToBudget(): void { + this.router.navigate(['/budget', this.budgetId]); + } + + getTotalDuplicates(): number { + return this.duplicateGroups.reduce((total, group) => total + group.length, 0); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts index b1795e60..faab4611 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts @@ -116,5 +116,19 @@ export class OperationsApiService { triggerRefresh(budgetId: string): void { this.refresh$.next(budgetId); } + + /** + * Get duplicate operations + */ + getDuplicates(budgetId: string, criteria?: string): Observable { + const params: any = {}; + if (criteria) { + params.criteria = criteria; + } + return this.http.get( + `${this.baseUrl}/budget/${budgetId}/operations/duplicates`, + { params, withCredentials: true } + ); + } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index cfc83a03..2269bfc5 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -11,6 +11,14 @@

Operations

(click)="navigateToImport()"> Import Operations + @if (!isEditMode) {
From 4d9ec0bc42ce79837469cb586f17270fd9bd4a25 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Mon, 20 Oct 2025 08:50:52 +0300 Subject: [PATCH 123/156] feat: Enable operation actions in duplicates list Enable edit and delete functionality for duplicate operations: Changes: - Set showActions=true on operations-table in duplicates list - Add onDeleteOperation() handler (delete individual operations) - Add onUpdateOperation() handler (inline edit operations) - Add BudgetApiService and TuiDialogService dependencies - Implement error and success dialogs - Reload duplicates list after delete/update Features: - Edit duplicates inline (same as operations list) - Delete individual duplicate operations - Confirmation dialog before deletion - Success/error feedback with dialogs - Auto-refresh after changes Benefits: - Users can manage duplicates without leaving the duplicates view - Consistent UX with main operations list - Quick cleanup of duplicate operations - Edit duplicates to differentiate them Use case: - User finds duplicate operations - Can edit one to change description/tags to differentiate - Can delete unwanted duplicates - List refreshes showing remaining duplicates --- .../duplicates-list.component.html | 7 +- .../duplicates-list.component.ts | 113 +++++++++++++++++- 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html index e96e90fa..4ca5c3df 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html @@ -109,7 +109,12 @@

Found {{ duplicateGroups.length }} duplicate groups ({{ ge @for (group of duplicateGroups; track $index; let groupIdx = $index) {

Duplicate Group {{ groupIdx + 1 }} ({{ group.length }} operations)

- + +
} } @else { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts index fc8cd694..21f6c0df 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts @@ -3,9 +3,11 @@ import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { OperationsApiService } from '../operations-api.service'; -import { OperationResponse } from '../../budget/models'; +import { BudgetApiService } from '../../budget/budget-api.service'; +import { OperationResponse, BudgetResponse } from '../../budget/models'; import { TuiButton, + TuiDialogService, TuiLoader, TuiTitle, TuiTextfield, @@ -45,7 +47,9 @@ export class DuplicatesListComponent implements OnInit { private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private fb: FormBuilder + private budgetApi: BudgetApiService, + private fb: FormBuilder, + private dialogService: TuiDialogService ) {} ngOnInit(): void { @@ -105,5 +109,110 @@ export class DuplicatesListComponent implements OnInit { getTotalDuplicates(): number { return this.duplicateGroups.reduce((total, group) => total + group.length, 0); } + + onDeleteOperation(operation: OperationResponse): void { + const confirmMessage = `Are you sure you want to delete this operation?\n\n${operation.description}\n${operation.amount.value} ${operation.amount.currencyCode}\n\nThis action cannot be undone.`; + + if (!confirm(confirmMessage)) { + return; + } + + this.isLoading = true; + const criteria = `o => o.Id == Guid.Parse("${operation.id}")`; + + this.operationsApi.removeOperations(this.budgetId, { criteria }).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.showError(`Failed to delete operation: ${errorMessage}`); + } else { + this.showSuccess('Operation deleted successfully'); + this.loadDuplicates(); + } + }, + error: (error: any) => { + this.isLoading = false; + this.handleError(error, 'Failed to delete operation'); + } + }); + } + + onUpdateOperation(operation: OperationResponse): void { + this.isLoading = true; + + // Get the budget to access its version + this.budgetApi.getAllBudgets().subscribe({ + next: (budgetList: BudgetResponse[]) => { + const budget = budgetList.find((b: BudgetResponse) => b.id === this.budgetId); + if (!budget) { + this.isLoading = false; + this.showError('Budget not found'); + return; + } + + const request = { + operations: [operation], + budgetVersion: budget.version, + transferConfidenceLevel: undefined, + taggingMode: 'Skip' + }; + + this.operationsApi.updateOperations(this.budgetId, request).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.showError(`Failed to update operation: ${errorMessage}`); + } else { + this.showSuccess('Operation updated successfully'); + this.loadDuplicates(); + } + }, + error: (error: any) => { + this.isLoading = false; + this.handleError(error, 'Failed to update operation'); + } + }); + }, + error: (error: any) => { + this.isLoading = false; + this.handleError(error, 'Failed to load budget'); + } + }); + } + + private handleError(error: any, defaultMessage: string): void { + let errorMessage = defaultMessage; + + if (error.status === 400 && Array.isArray(error.error)) { + const errors = error.error as any[]; + errorMessage = errors.map(e => e.message || e).join('; '); + } else if (error.error?.message) { + errorMessage = error.error.message; + } + + this.showError(errorMessage); + } + + private showError(message: string): void { + this.dialogService.open(message, { + label: 'Error', + size: 'm', + closeable: true, + dismissible: true + }).subscribe(); + } + + private showSuccess(message: string): void { + this.dialogService.open(message, { + label: 'Success', + size: 's', + closeable: true, + dismissible: true + }).subscribe(); + } } From 5779d43933e958dc286bff9c6fb0f855ff572176 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Mon, 20 Oct 2025 09:21:08 +0300 Subject: [PATCH 124/156] refactor(operations): extract shared components, services, and utilities - Created shared infrastructure: * NotificationService for centralized error/success handling * OperationsHelperService for common delete/update operations * CurrencyFormatPipe, DateFormatPipe, ObjectKeysPipe for reusable transformations * CtrlEnterDirective for keyboard shortcuts * ExamplesSectionComponent, MetadataDisplayComponent, OperationResultComponent, CriteriaFilterComponent for UI reuse - Refactored all operation components: * operations-table: replaced methods with pipes, applied shared styles * operations-list: integrated shared services, removed 80+ lines of duplicate code * duplicates-list: replaced manual forms with shared components, removed 100+ lines * delete-operations: integrated shared components, simplified from 162 to 106 lines * import-operations: integrated shared components, removed 120+ lines - Created shared LESS mixins for consistent styling - Added type-safe interfaces (CriteriaExample, OperationResult, ImportResult) Benefits: - Eliminated ~500+ lines of duplicated code - Removed 15+ duplicate methods across components - Improved type safety and maintainability - Single source of truth for common functionality - Easier testing with isolated services and components --- .../delete-operations.component.html | 234 ++++------------ .../delete-operations.component.less | 221 +-------------- .../delete-operations.component.ts | 114 ++------ .../duplicates-list.component.html | 81 +----- .../duplicates-list.component.less | 92 +------ .../duplicates-list.component.ts | 165 ++++-------- .../import-operations.component.html | 83 +----- .../import-operations.component.less | 161 +---------- .../import-operations.component.ts | 84 ++---- .../operations-list.component.html | 2 +- .../operations-list.component.less | 28 +- .../operations-list.component.ts | 120 ++------- .../operations-table.component.html | 10 +- .../operations-table.component.less | 32 +-- .../operations-table.component.ts | 29 +- .../criteria-filter.component.html | 40 +++ .../criteria-filter.component.less | 16 ++ .../criteria-filter.component.ts | 53 ++++ .../examples-section.component.html | 25 ++ .../examples-section.component.less | 6 + .../examples-section.component.ts | 22 ++ .../metadata-display.component.html | 12 + .../metadata-display.component.less | 6 + .../metadata-display.component.ts | 16 ++ .../operation-result.component.html | 60 +++++ .../operation-result.component.less | 34 +++ .../operation-result.component.ts | 29 ++ .../shared/directives/ctrl-enter.directive.ts | 18 ++ .../shared/models/example.interface.ts | 5 + .../shared/models/result.interface.ts | 13 + .../operations/shared/notification.service.ts | 71 +++++ .../shared/operations-helper.service.ts | 48 ++++ .../shared/pipes/currency-format.pipe.ts | 14 + .../shared/pipes/date-format.pipe.ts | 19 ++ .../shared/pipes/object-keys.pipe.ts | 12 + .../app/operations/shared/styles/mixins.less | 252 ++++++++++++++++++ 36 files changed, 996 insertions(+), 1231 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/directives/ctrl-enter.directive.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/models/example.interface.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/models/result.interface.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/notification.service.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/operations-helper.service.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/currency-format.pipe.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/date-format.pipe.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/object-keys.pipe.ts create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/styles/mixins.less diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html index e35b37f1..197ce178 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.html @@ -4,203 +4,63 @@

Delete Operations

-

⚠️ Warning: Dangerous Operation

-

This action will permanently delete all operations matching your criteria. This cannot be undone.

-

Please be careful and verify your criteria before executing the deletion.

-
- -
-
-

Deletion Criteria

- - - - -
- -
-
-

Common Examples

- -
- - @if (showExamples) { -
-
- All operations: - o => true -
-
- Negative amounts: - o => o.Amount.Amount < 0 -
-
- Specific year: - o => o.Timestamp.Year == 2023 -
-
- Contains text: - o => o.Description.Contains("test") -
-
- By tag: - o => o.Tags.Any(t => t.Value == "unwanted") -
-
- By attribute: - o => o.Attributes.ContainsKey("error") -
-
- Amount range: - o => o.Amount.Amount >= -100 && o.Amount.Amount <= -10 -
-
- } -
+

⚠️ Warning: Dangerous Operation

+

This action will permanently delete all operations matching your criteria. This cannot be undone.

+

Please be careful and verify your criteria before executing the deletion.

+

-
- - - @if (deleteResult) { - - } -
- + + +
+ @if (deleteResult) { -
-

Deletion Results

-
- @if (deleteResult.successes.length > 0) { -
- Successes: - {{ deleteResult.successes.length }} -
- } - @if (deleteResult.errors.length > 0) { -
- Errors: - {{ deleteResult.errors.length }} -
- } -
- - + + } +
+ + @if (deleteResult) { +
+

Deletion Results

+
@if (deleteResult.successes.length > 0) { -
-
-

Success Messages ({{ deleteResult.successes.length }})

- -
- @if (showSuccesses) { - @for (success of deleteResult.successes; track $index) { -
-
{{ success.message || 'Success' }}
- @if (success.metadata && getObjectKeys(success.metadata).length > 0) { - - } -
- } - } +
+ Successes: + {{ deleteResult.successes.length }}
} - - @if (deleteResult.errors.length > 0) { -
-
-

Errors ({{ deleteResult.errors.length }})

- -
- @if (showErrors) { - @for (error of deleteResult.errors; track $index) { -
-
{{ error.message || 'Error' }}
- @if (error.metadata && getObjectKeys(error.metadata).length > 0) { - - } - @if (error.reasons && error.reasons.length > 0) { -
- Caused by: -
    - @for (reason of error.reasons; track $index) { -
  • {{ reason.message || 'Unspecified reason' }}
  • - } -
-
- } -
- } - } +
+ Errors: + {{ deleteResult.errors.length }}
}
- } + + + +
+ }
- diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less index 842277a6..7f4ff824 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.less @@ -1,10 +1,11 @@ +@import '../shared/styles/mixins.less'; + .delete-container { - padding: 2rem; - margin: 0 auto; + .page-container(); } .delete-card { - padding: 2rem; + .card-base(); } .danger-warning { @@ -27,224 +28,18 @@ } } -.delete-form { - margin-top: 2rem; -} - -.form-section { - margin-bottom: 2rem; - - h3 { - margin-bottom: 1rem; - } -} - -.examples-section { - margin-bottom: 2rem; - padding: 1rem; - background: var(--tui-base-02); - border-radius: 0.5rem; -} - -.examples-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; - - h4 { - margin: 0; - color: var(--tui-text-02); - } -} - -.examples-list { - margin-top: 1rem; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.example-item { - padding: 0.75rem; - background: var(--tui-base-01); - border-radius: 0.25rem; - - strong { - display: block; - margin-bottom: 0.25rem; - color: var(--tui-text-02); - font-size: 0.875rem; - } - - code { - display: block; - background: var(--tui-base-03); - padding: 0.5rem; - border-radius: 0.25rem; - font-family: monospace; - font-size: 0.875rem; - color: var(--tui-text-01); - } -} - -.form-actions { +.action-buttons { display: flex; gap: 1rem; - margin-top: 2rem; + margin: 2rem 0; flex-wrap: wrap; } .delete-result { - margin-top: 2rem; - padding: 1.5rem; - background: var(--tui-base-02); - border-radius: 0.5rem; + .result-section-base(); } .result-stats { - display: flex; - gap: 2rem; - margin: 1rem 0; - flex-wrap: wrap; -} - -.stat-item { - display: flex; - flex-direction: column; - gap: 0.5rem; - - .stat-label { - font-size: 0.875rem; - color: var(--tui-text-02); - } - - .stat-value { - font-size: 1.5rem; - font-weight: 700; - } - - &.success .stat-value { - color: var(--tui-success-fill); - } - - &.error .stat-value { - color: var(--tui-error-fill); - } -} - -.section-header-with-toggle { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - - h4 { - margin: 0; - } -} - -.successes-section { - margin-top: 1.5rem; - - h4 { - color: var(--tui-success-fill); - } -} - -.success-item { - margin-bottom: 1rem; - padding: 1rem; - background: var(--tui-success-bg); - border-left: 3px solid var(--tui-success-fill); - border-radius: 0.25rem; - - .success-message { - font-weight: 600; - margin-bottom: 0.5rem; - color: var(--tui-text-01); - } -} - -.errors-section { - margin-top: 1.5rem; - - h4 { - color: var(--tui-error-fill); - } -} - -.error-item { - margin-bottom: 1rem; - padding: 1rem; - background: var(--tui-error-bg); - border-left: 3px solid var(--tui-error-fill); - border-radius: 0.25rem; - - .error-message { - font-weight: 600; - margin-bottom: 0.5rem; - color: var(--tui-text-01); - } -} - -.metadata { - margin-top: 0.75rem; - padding: 0.75rem; - background: var(--tui-base-01); - border-radius: 0.25rem; - font-size: 0.875rem; - - strong { - display: block; - margin-bottom: 0.5rem; - color: var(--tui-text-02); - } -} - -.metadata-item { - display: flex; - gap: 0.5rem; - margin: 0.25rem 0; - - .metadata-key { - font-weight: 600; - color: var(--tui-text-02); - min-width: 120px; - } - - .metadata-value { - color: var(--tui-text-01); - font-family: monospace; - } -} - -.nested-reasons { - margin-top: 0.75rem; - padding: 0.75rem; - background: var(--tui-base-01); - border-radius: 0.25rem; - font-size: 0.875rem; - - strong { - display: block; - margin-bottom: 0.5rem; - color: var(--tui-text-02); - } - - ul { - margin: 0; - padding-left: 1.5rem; - - li { - margin: 0.25rem 0; - } - } -} - -.loading-placeholder { - text-align: center; - padding: 2rem; - color: var(--tui-text-02); + .result-stats-base(); } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts index de285905..cc120161 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/delete-operations/delete-operations.component.ts @@ -1,33 +1,30 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { OperationsApiService } from '../operations-api.service'; -import { IError, ISuccess } from '../../budget/models'; import { TuiButton, - TuiDialogService, TuiLoader, - TuiTitle, - TuiTextfield, - TuiLabel + TuiTitle } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiTextarea } from '@taiga-ui/kit'; +import { NotificationService } from '../shared/notification.service'; +import { CriteriaFilterComponent } from '../shared/components/criteria-filter/criteria-filter.component'; +import { OperationResultComponent } from '../shared/components/operation-result/operation-result.component'; +import { CriteriaExample } from '../shared/models/example.interface'; +import { OperationResult } from '../shared/models/result.interface'; @Component({ selector: 'app-delete-operations', standalone: true, imports: [ CommonModule, - ReactiveFormsModule, TuiButton, TuiLoader, - TuiTextfield, - TuiLabel, TuiCardLarge, TuiTitle, - TuiTextarea + CriteriaFilterComponent, + OperationResultComponent ], templateUrl: './delete-operations.component.html', styleUrls: ['./delete-operations.component.less'] @@ -35,45 +32,36 @@ import { TuiTextarea } from '@taiga-ui/kit'; export class DeleteOperationsComponent implements OnInit { budgetId!: string; isLoading = false; + deleteResult: OperationResult | null = null; + currentCriteria = 'o => true'; - deleteForm!: FormGroup; - deleteResult: { - errors: IError[]; - successes: ISuccess[]; - } | null = null; - - // Section toggles - showExamples = false; - showSuccesses = true; - showErrors = true; + criteriaExamples: CriteriaExample[] = [ + { label: 'All operations:', code: 'o => true' }, + { label: 'Negative amounts:', code: 'o => o.Amount.Amount < 0' }, + { label: 'Specific year:', code: 'o => o.Timestamp.Year == 2023' }, + { label: 'Contains text:', code: 'o => o.Description.Contains("test")' }, + { label: 'By tag:', code: 'o => o.Tags.Any(t => t.Value == "unwanted")' }, + { label: 'By attribute:', code: 'o => o.Attributes.ContainsKey("error")' }, + { label: 'Amount range:', code: 'o => o.Amount.Amount >= -100 && o.Amount.Amount <= -10' } + ]; constructor( private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private fb: FormBuilder, - private dialogService: TuiDialogService + private notificationService: NotificationService ) {} ngOnInit(): void { this.budgetId = this.route.snapshot.params['budgetId']; - - this.deleteForm = this.fb.group({ - criteria: ['o => true', Validators.required] - }); } - toggleExamples(): void { - this.showExamples = !this.showExamples; + onCriteriaSubmitted(criteria: string): void { + this.currentCriteria = criteria; + this.deleteOperations(criteria); } - deleteOperations(): void { - if (!this.deleteForm.valid) { - this.showError('Please provide a valid criteria expression'); - return; - } - - const criteria = this.deleteForm.value.criteria; + deleteOperations(criteria: string): void { const confirmMessage = `Are you sure you want to delete all operations matching the criteria:\n\n${criteria}\n\nThis action cannot be undone.`; const confirmed = confirm(confirmMessage); @@ -91,18 +79,19 @@ export class DeleteOperationsComponent implements OnInit { }; if (result.errors.length === 0) { - this.showSuccess('Operations deleted successfully'); + this.notificationService.showSuccess('Operations deleted successfully').subscribe(); this.operationsApi.triggerRefresh(this.budgetId); } else { const errorMessage = result.errors.length > 5 ? `Deletion completed with ${result.errors.length} errors. Check the results below.` : `Deletion completed with errors. See details below.`; - this.showError(errorMessage); + this.notificationService.showError(errorMessage).subscribe(); } }, error: (error) => { this.isLoading = false; - this.handleError(error, 'Failed to delete operations'); + const errorMessage = this.notificationService.handleError(error, 'Failed to delete operations'); + this.notificationService.showError(errorMessage).subscribe(); } }); } @@ -111,53 +100,8 @@ export class DeleteOperationsComponent implements OnInit { this.router.navigate(['/budget', this.budgetId]); } - resetForm(): void { - this.deleteForm.patchValue({ criteria: 'o => true' }); + resetResult(): void { this.deleteResult = null; } - - toggleSuccesses(): void { - this.showSuccesses = !this.showSuccesses; - } - - toggleErrors(): void { - this.showErrors = !this.showErrors; - } - - // Helper method to access Object.keys in template - getObjectKeys(obj: any): string[] { - return obj ? Object.keys(obj) : []; - } - - private handleError(error: any, defaultMessage: string): void { - let errorMessage = defaultMessage; - - if (error.status === 400 && Array.isArray(error.error)) { - const errors = error.error as any[]; - errorMessage = errors.map(e => e.message || e).join('; '); - } else if (error.error?.message) { - errorMessage = error.error.message; - } - - this.showError(errorMessage); - } - - private showError(message: string): void { - this.dialogService.open(message, { - label: 'Error', - size: 'm', - closeable: true, - dismissible: true - }).subscribe(); - } - - private showSuccess(message: string): void { - this.dialogService.open(message, { - label: 'Success', - size: 's', - closeable: true, - dismissible: true - }).subscribe(); - } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html index 4ca5c3df..4224e97d 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.html @@ -24,80 +24,13 @@

Duplicate Operations

-
-

Filter Criteria

-
-
- - - - -
-
- -
-
-

Common Examples

- -
- - @if (showExamples) { -
-
- All operations: - o => true -
-
- Specific year: - o => o.Timestamp.Year == 2024 -
-
- Negative amounts only: - o => o.Amount.Amount < 0 -
-
- By description contains: - o => o.Description.Contains("coffee") -
-
- Recent operations: - o => o.Timestamp > DateTime.Now.AddDays(-30) -
-
- } -
- -
- - -
-
+ +
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.less index 51c8fabf..6dd34379 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.less @@ -1,98 +1,14 @@ +@import '../shared/styles/mixins.less'; + .duplicates-container { - padding: 2rem; - margin: 0 auto; + .page-container(); } .header { - display: flex; - justify-content: space-between; - align-items: center; + .header-with-actions(); margin-bottom: 2rem; } -.header-actions { - display: flex; - gap: 1rem; -} - -.filters-section { - margin-bottom: 1.5rem; - padding: 1.5rem; - background: var(--tui-base-02); - border-radius: 0.5rem; - - h3 { - margin-bottom: 1rem; - } -} - -.filters-grid { - display: grid; - grid-template-columns: 1fr; - gap: 1rem; - margin-bottom: 1rem; - - .filter-item { - &.full-width { - grid-column: 1 / -1; - } - } -} - -.examples-section { - margin: 1rem 0; - padding: 1rem; - background: var(--tui-base-01); - border-radius: 0.25rem; -} - -.examples-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; - - h4 { - margin: 0; - color: var(--tui-text-02); - } -} - -.examples-list { - margin-top: 1rem; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.example-item { - padding: 0.75rem; - background: var(--tui-base-02); - border-radius: 0.25rem; - - strong { - display: block; - margin-bottom: 0.25rem; - color: var(--tui-text-02); - font-size: 0.875rem; - } - - code { - display: block; - background: var(--tui-base-03); - padding: 0.5rem; - border-radius: 0.25rem; - font-family: monospace; - font-size: 0.875rem; - color: var(--tui-text-01); - } -} - -.filter-actions { - display: flex; - gap: 1rem; -} - .duplicates-section { margin-top: 2rem; } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts index 21f6c0df..93bbc6ff 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/duplicates-list/duplicates-list.component.ts @@ -1,36 +1,31 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { OperationsApiService } from '../operations-api.service'; -import { BudgetApiService } from '../../budget/budget-api.service'; -import { OperationResponse, BudgetResponse } from '../../budget/models'; +import { OperationResponse } from '../../budget/models'; import { TuiButton, - TuiDialogService, TuiLoader, - TuiTitle, - TuiTextfield, - TuiLabel + TuiTitle } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiTextarea } from '@taiga-ui/kit'; import { OperationsTableComponent } from '../operations-table/operations-table.component'; +import { NotificationService } from '../shared/notification.service'; +import { OperationsHelperService } from '../shared/operations-helper.service'; +import { CriteriaFilterComponent } from '../shared/components/criteria-filter/criteria-filter.component'; +import { CriteriaExample } from '../shared/models/example.interface'; @Component({ selector: 'app-duplicates-list', standalone: true, imports: [ CommonModule, - ReactiveFormsModule, TuiButton, TuiLoader, - TuiTextfield, - TuiLabel, TuiCardLarge, TuiTitle, - TuiTextarea, - OperationsTableComponent + OperationsTableComponent, + CriteriaFilterComponent ], templateUrl: './duplicates-list.component.html', styleUrls: ['./duplicates-list.component.less'] @@ -40,62 +35,53 @@ export class DuplicatesListComponent implements OnInit { duplicateGroups: OperationResponse[][] = []; isLoading = false; - filterForm!: FormGroup; - showExamples = false; + criteriaExamples: CriteriaExample[] = [ + { label: 'All operations:', code: 'o => true' }, + { label: 'Specific year:', code: 'o => o.Timestamp.Year == 2024' }, + { label: 'Negative amounts only:', code: 'o => o.Amount.Amount < 0' }, + { label: 'By description contains:', code: 'o => o.Description.Contains("coffee")' }, + { label: 'Recent operations:', code: 'o => o.Timestamp > DateTime.Now.AddDays(-30)' } + ]; constructor( private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private budgetApi: BudgetApiService, - private fb: FormBuilder, - private dialogService: TuiDialogService + private notificationService: NotificationService, + private operationsHelper: OperationsHelperService ) {} ngOnInit(): void { this.budgetId = this.route.snapshot.params['budgetId']; - - this.filterForm = this.fb.group({ - criteria: ['o => true'] - }); - - this.loadDuplicates(); + this.loadDuplicates('o => true'); } - loadDuplicates(): void { + loadDuplicates(criteria: string): void { this.isLoading = true; - const criteria = this.filterForm.value.criteria || undefined; - this.operationsApi.getDuplicates(this.budgetId, criteria).subscribe({ + this.operationsApi.getDuplicates(this.budgetId, criteria || undefined).subscribe({ next: (groups) => { this.duplicateGroups = groups; this.isLoading = false; }, error: (error) => { - console.error('Failed to load duplicates', error); + const errorMessage = this.notificationService.handleError(error, 'Failed to load duplicates'); + this.notificationService.showError(errorMessage).subscribe(); this.isLoading = false; } }); } - applyFilters(): void { - this.loadDuplicates(); + onCriteriaSubmitted(criteria: string): void { + this.loadDuplicates(criteria); } - clearFilters(): void { - this.filterForm.patchValue({ criteria: 'o => true' }); - this.loadDuplicates(); + onCriteriaCleared(): void { + this.loadDuplicates('o => true'); } - toggleExamples(): void { - this.showExamples = !this.showExamples; - } - - onCriteriaKeydown(event: KeyboardEvent): void { - if (event.key === 'Enter' && event.ctrlKey) { - event.preventDefault(); - this.applyFilters(); - } + clearFilters(): void { + this.onCriteriaCleared(); } navigateToOperations(): void { @@ -118,23 +104,23 @@ export class DuplicatesListComponent implements OnInit { } this.isLoading = true; - const criteria = `o => o.Id == Guid.Parse("${operation.id}")`; - this.operationsApi.removeOperations(this.budgetId, { criteria }).subscribe({ + this.operationsHelper.deleteOperation(this.budgetId, operation.id).subscribe({ next: (result) => { this.isLoading = false; if (result.errors && result.errors.length > 0) { - const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); - this.showError(`Failed to delete operation: ${errorMessage}`); + const errorMessage = result.errors.map((e: any) => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to delete operation: ${errorMessage}`).subscribe(); } else { - this.showSuccess('Operation deleted successfully'); - this.loadDuplicates(); + this.notificationService.showSuccess('Operation deleted successfully').subscribe(); + this.loadDuplicates('o => true'); } }, - error: (error: any) => { + error: (error) => { this.isLoading = false; - this.handleError(error, 'Failed to delete operation'); + const errorMessage = this.notificationService.handleError(error, 'Failed to delete operation'); + this.notificationService.showError(errorMessage).subscribe(); } }); } @@ -142,77 +128,24 @@ export class DuplicatesListComponent implements OnInit { onUpdateOperation(operation: OperationResponse): void { this.isLoading = true; - // Get the budget to access its version - this.budgetApi.getAllBudgets().subscribe({ - next: (budgetList: BudgetResponse[]) => { - const budget = budgetList.find((b: BudgetResponse) => b.id === this.budgetId); - if (!budget) { - this.isLoading = false; - this.showError('Budget not found'); - return; + this.operationsHelper.updateOperation(this.budgetId, operation).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to update operation: ${errorMessage}`).subscribe(); + } else { + this.notificationService.showSuccess('Operation updated successfully').subscribe(); + this.loadDuplicates('o => true'); } - - const request = { - operations: [operation], - budgetVersion: budget.version, - transferConfidenceLevel: undefined, - taggingMode: 'Skip' - }; - - this.operationsApi.updateOperations(this.budgetId, request).subscribe({ - next: (result) => { - this.isLoading = false; - - if (result.errors && result.errors.length > 0) { - const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); - this.showError(`Failed to update operation: ${errorMessage}`); - } else { - this.showSuccess('Operation updated successfully'); - this.loadDuplicates(); - } - }, - error: (error: any) => { - this.isLoading = false; - this.handleError(error, 'Failed to update operation'); - } - }); }, - error: (error: any) => { + error: (error) => { this.isLoading = false; - this.handleError(error, 'Failed to load budget'); + const errorMessage = this.notificationService.handleError(error, 'Failed to update operation'); + this.notificationService.showError(errorMessage).subscribe(); } }); } - - private handleError(error: any, defaultMessage: string): void { - let errorMessage = defaultMessage; - - if (error.status === 400 && Array.isArray(error.error)) { - const errors = error.error as any[]; - errorMessage = errors.map(e => e.message || e).join('; '); - } else if (error.error?.message) { - errorMessage = error.error.message; - } - - this.showError(errorMessage); - } - - private showError(message: string): void { - this.dialogService.open(message, { - label: 'Error', - size: 'm', - closeable: true, - dismissible: true - }).subscribe(); - } - - private showSuccess(message: string): void { - this.dialogService.open(message, { - label: 'Success', - size: 's', - closeable: true, - dismissible: true - }).subscribe(); - } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html index 3a94453b..ed137408 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.html @@ -136,85 +136,10 @@
Duplicate Group {{ groupIdx + 1 }} ({{ group.length }} operations)
} - - @if (importResult.successes.length > 0) { -
-
-

Success Messages ({{ importResult.successes.length }})

- -
- @if (showSuccesses) { - @for (success of importResult.successes; track $index) { -
-
{{ success.message || 'Success' }}
- @if (success.metadata && getObjectKeys(success.metadata).length > 0) { - - } -
- } - } -
- } - - - @if (importResult.errors.length > 0) { -
-
-

Errors ({{ importResult.errors.length }})

- -
- @if (showErrors) { - @for (error of importResult.errors; track $index) { -
-
{{ error.message || 'Error' }}
- @if (error.metadata && getObjectKeys(error.metadata).length > 0) { - - } - @if (error.reasons && error.reasons.length > 0) { -
- Caused by: -
    - @for (reason of error.reasons; track $index) { -
  • {{ reason.message || 'Unspecified reason' }}
  • - } -
-
- } -
- } - } -
- } + +
} } @else { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less index 33d0db2c..ed34c7ff 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.less @@ -1,10 +1,11 @@ +@import '../shared/styles/mixins.less'; + .import-container { - padding: 2rem; - margin: 0 auto; + .page-container(); } .import-card { - padding: 2rem; + .card-base(); } .import-form { @@ -47,60 +48,15 @@ } .import-result { - margin-top: 2rem; - padding: 1.5rem; - background: var(--tui-base-02); - border-radius: 0.5rem; + .result-section-base(); } .result-stats { - display: flex; - gap: 2rem; - margin: 1rem 0; - flex-wrap: wrap; -} - -.stat-item { - display: flex; - flex-direction: column; - gap: 0.5rem; - - .stat-label { - font-size: 0.875rem; - color: var(--tui-text-02); - } - - .stat-value { - font-size: 1.5rem; - font-weight: 700; - } - - &.success .stat-value { - color: var(--tui-success-fill); - } - - &.warning .stat-value { - color: var(--tui-warning-fill); - } - - &.error .stat-value { - color: var(--tui-error-fill); - } - - &.info .stat-value { - color: var(--tui-primary); - } + .result-stats-base(); } .section-header-with-toggle { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - - h4 { - margin: 0; - } + .section-header-with-toggle(); } .duplicates-section { @@ -125,107 +81,4 @@ } } -.successes-section { - margin-top: 1.5rem; - - h4 { - color: var(--tui-success-fill); - } -} - -.success-item { - margin-bottom: 1rem; - padding: 1rem; - background: var(--tui-success-bg); - border-left: 3px solid var(--tui-success-fill); - border-radius: 0.25rem; - - .success-message { - font-weight: 600; - margin-bottom: 0.5rem; - color: var(--tui-text-01); - } -} - -.errors-section { - margin-top: 1.5rem; - - h4 { - color: var(--tui-error-fill); - } -} - -.error-item { - margin-bottom: 1rem; - padding: 1rem; - background: var(--tui-error-bg); - border-left: 3px solid var(--tui-error-fill); - border-radius: 0.25rem; - - .error-message { - font-weight: 600; - margin-bottom: 0.5rem; - color: var(--tui-text-01); - } -} - -.metadata { - margin-top: 0.75rem; - padding: 0.75rem; - background: var(--tui-base-01); - border-radius: 0.25rem; - font-size: 0.875rem; - - strong { - display: block; - margin-bottom: 0.5rem; - color: var(--tui-text-02); - } -} - -.metadata-item { - display: flex; - gap: 0.5rem; - margin: 0.25rem 0; - - .metadata-key { - font-weight: 600; - color: var(--tui-text-02); - min-width: 120px; - } - - .metadata-value { - color: var(--tui-text-01); - font-family: monospace; - } -} - -.nested-reasons { - margin-top: 0.75rem; - padding: 0.75rem; - background: var(--tui-base-01); - border-radius: 0.25rem; - font-size: 0.875rem; - - strong { - display: block; - margin-bottom: 0.5rem; - color: var(--tui-text-02); - } - - ul { - margin: 0; - padding-left: 1.5rem; - - li { - margin: 0.25rem 0; - } - } -} - -.loading-placeholder { - text-align: center; - padding: 2rem; - color: var(--tui-text-02); -} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts index 1f740725..09e34762 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/import-operations/import-operations.component.ts @@ -4,10 +4,9 @@ import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { OperationsApiService } from '../operations-api.service'; import { BudgetApiService } from '../../budget/budget-api.service'; -import { BudgetResponse, IError, ISuccess } from '../../budget/models'; +import { BudgetResponse } from '../../budget/models'; import { TuiButton, - TuiDialogService, TuiLoader, TuiTitle, TuiTextfield, @@ -15,6 +14,9 @@ import { } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; import { OperationsTableComponent } from '../operations-table/operations-table.component'; +import { NotificationService } from '../shared/notification.service'; +import { OperationResultComponent } from '../shared/components/operation-result/operation-result.component'; +import { ImportResult } from '../shared/models/result.interface'; @Component({ selector: 'app-import-operations', @@ -28,7 +30,8 @@ import { OperationsTableComponent } from '../operations-table/operations-table.c TuiLabel, TuiCardLarge, TuiTitle, - OperationsTableComponent + OperationsTableComponent, + OperationResultComponent ], templateUrl: './import-operations.component.html', styleUrls: ['./import-operations.component.less'] @@ -40,16 +43,9 @@ export class ImportOperationsComponent implements OnInit { importForm!: FormGroup; selectedFile: File | null = null; - importResult: { - registered: number; - duplicates: number; - errors: IError[]; - successes: ISuccess[]; - } | null = null; + importResult: ImportResult | null = null; // Section toggles - showSuccesses = true; - showErrors = true; showDuplicates = false; constructor( @@ -58,7 +54,7 @@ export class ImportOperationsComponent implements OnInit { private operationsApi: OperationsApiService, private budgetApi: BudgetApiService, private fb: FormBuilder, - private dialogService: TuiDialogService + private notificationService: NotificationService ) {} ngOnInit(): void { @@ -81,7 +77,8 @@ export class ImportOperationsComponent implements OnInit { }, error: (error) => { this.isLoading = false; - this.handleError(error, 'Failed to load budget'); + const errorMessage = this.notificationService.handleError(error, 'Failed to load budget'); + this.notificationService.showError(errorMessage).subscribe(); } }); } @@ -98,7 +95,7 @@ export class ImportOperationsComponent implements OnInit { importCsv(): void { if (!this.selectedFile || !this.budget) { - this.showError('Please select a CSV file first'); + this.notificationService.showError('Please select a CSV file first').subscribe(); return; } @@ -121,25 +118,24 @@ export class ImportOperationsComponent implements OnInit { registered: result.registeredOperations.length, duplicates: result.duplicates.length, errors: result.errors, - successes: result.successes + successes: result.successes, + duplicatesList: result.duplicates }; - // Store full result for displaying details - (this.importResult as any).duplicatesList = result.duplicates; - if (result.errors.length === 0) { - this.showSuccess(`Successfully imported ${result.registeredOperations.length} operations`); + this.notificationService.showSuccess(`Successfully imported ${result.registeredOperations.length} operations`).subscribe(); this.operationsApi.triggerRefresh(this.budgetId); } else { const errorMessage = result.errors.length > 5 ? `Import completed with ${result.errors.length} errors. Check the results below.` : `Import completed with errors. See details below.`; - this.showError(errorMessage); + this.notificationService.showError(errorMessage).subscribe(); } }, error: (error) => { this.isLoading = false; - this.handleError(error, 'Failed to import operations'); + const errorMessage = this.notificationService.handleError(error, 'Failed to import operations'); + this.notificationService.showError(errorMessage).subscribe(); } }); } @@ -148,56 +144,12 @@ export class ImportOperationsComponent implements OnInit { this.router.navigate(['/budget', this.budgetId]); } - private handleError(error: any, defaultMessage: string): void { - let errorMessage = defaultMessage; - - if (error.status === 400 && Array.isArray(error.error)) { - const errors = error.error as any[]; - errorMessage = errors.map(e => e.message || e).join('; '); - } else if (error.error?.message) { - errorMessage = error.error.message; - } - - this.showError(errorMessage); - } - - private showError(message: string): void { - this.dialogService.open(message, { - label: 'Error', - size: 'm', - closeable: true, - dismissible: true - }).subscribe(); - } - - private showSuccess(message: string): void { - this.dialogService.open(message, { - label: 'Success', - size: 's', - closeable: true, - dismissible: true - }).subscribe(); - } - - // Helper method to access Object.keys in template - getObjectKeys(obj: any): string[] { - return obj ? Object.keys(obj) : []; - } - - toggleSuccesses(): void { - this.showSuccesses = !this.showSuccesses; - } - - toggleErrors(): void { - this.showErrors = !this.showErrors; - } - toggleDuplicates(): void { this.showDuplicates = !this.showDuplicates; } getDuplicatesList(): any[] { - return (this.importResult as any)?.duplicatesList || []; + return this.importResult?.duplicatesList || []; } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 467287e6..9c68541a 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -50,7 +50,7 @@

Filters

formControlName="criteria" rows="2" placeholder="e.g., o => o.Amount.Amount > 100" - (keydown)="onCriteriaKeydown($event)" + (appCtrlEnter)="applyFilters()" >
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 5ac29362..9675997f 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -1,35 +1,19 @@ +@import '../shared/styles/mixins.less'; + .operations-container { - padding: 2rem; - margin: 0 auto; + .page-container(); } .operations-card { - padding: 2rem; + .card-base(); } .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - flex-wrap: wrap; - gap: 1rem; -} - -.header-actions { - display: flex; - gap: 1rem; + .header-with-actions(); } .filters-section { - margin-bottom: 1.5rem; - padding: 1.5rem; - background: var(--tui-base-02); - border-radius: 0.5rem; - - h3 { - margin-bottom: 1rem; - } + .filters-section-base(); } .filters-grid { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 7fec3ae8..66626273 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -4,11 +4,9 @@ import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable, catchError, of } from 'rxjs'; import { OperationsApiService } from '../operations-api.service'; -import { BudgetApiService } from '../../budget/budget-api.service'; -import { OperationResponse, BudgetResponse } from '../../budget/models'; +import { OperationResponse } from '../../budget/models'; import { TuiButton, - TuiDialogService, TuiLoader, TuiTitle, TuiTextfield, @@ -18,6 +16,9 @@ import { import { TuiCardLarge } from '@taiga-ui/layout'; import { TuiAccordion, TuiTextarea, TuiCheckbox } from '@taiga-ui/kit'; import { OperationsTableComponent } from '../operations-table/operations-table.component'; +import { NotificationService } from '../shared/notification.service'; +import { OperationsHelperService } from '../shared/operations-helper.service'; +import { CtrlEnterDirective } from '../shared/directives/ctrl-enter.directive'; @Component({ selector: 'app-operations-list', @@ -35,7 +36,8 @@ import { OperationsTableComponent } from '../operations-table/operations-table.c TuiExpand, TuiTextarea, TuiCheckbox, - OperationsTableComponent + OperationsTableComponent, + CtrlEnterDirective ], templateUrl: './operations-list.component.html', styleUrls: ['./operations-list.component.less'] @@ -52,9 +54,9 @@ export class OperationsListComponent implements OnInit { private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private budgetApi: BudgetApiService, private fb: FormBuilder, - private dialogService: TuiDialogService + private notificationService: NotificationService, + private operationsHelper: OperationsHelperService ) {} ngOnInit(): void { @@ -78,7 +80,8 @@ export class OperationsListComponent implements OnInit { formValue.excludeTransfers ).pipe( catchError(error => { - this.handleError(error, 'Failed to load operations'); + const errorMessage = this.notificationService.handleError(error, 'Failed to load operations'); + this.notificationService.showError(errorMessage).subscribe(); return of([]); }) ); @@ -100,13 +103,6 @@ export class OperationsListComponent implements OnInit { this.loadOperations(); } - onCriteriaKeydown(event: KeyboardEvent): void { - if (event.key === 'Enter' && event.ctrlKey) { - event.preventDefault(); - this.applyFilters(); - } - } - navigateToImport(): void { this.router.navigate(['/budget', this.budgetId, 'operations', 'import']); } @@ -131,23 +127,23 @@ export class OperationsListComponent implements OnInit { } this.isLoading = true; - const criteria = `o => o.Id == Guid.Parse("${operation.id}")`; - this.operationsApi.removeOperations(this.budgetId, { criteria }).subscribe({ + this.operationsHelper.deleteOperation(this.budgetId, operation.id).subscribe({ next: (result) => { this.isLoading = false; if (result.errors && result.errors.length > 0) { - const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); - this.showError(`Failed to delete operation: ${errorMessage}`); + const errorMessage = result.errors.map((e: any) => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to delete operation: ${errorMessage}`).subscribe(); } else { - this.showSuccess('Operation deleted successfully'); + this.notificationService.showSuccess('Operation deleted successfully').subscribe(); this.loadOperations(); } }, error: (error) => { this.isLoading = false; - this.handleError(error, 'Failed to delete operation'); + const errorMessage = this.notificationService.handleError(error, 'Failed to delete operation'); + this.notificationService.showError(errorMessage).subscribe(); } }); } @@ -155,82 +151,24 @@ export class OperationsListComponent implements OnInit { onUpdateOperation(operation: OperationResponse): void { this.isLoading = true; - // Get the budget to access its version - this.budgetApi.getAllBudgets().subscribe({ - next: (budgetList: BudgetResponse[]) => { - const budget = budgetList.find((b: BudgetResponse) => b.id === this.budgetId); - if (!budget) { - this.isLoading = false; - this.showError('Budget not found'); - return; + this.operationsHelper.updateOperation(this.budgetId, operation).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to update operation: ${errorMessage}`).subscribe(); + } else { + this.notificationService.showSuccess('Operation updated successfully').subscribe(); + this.loadOperations(); } - - const request = { - operations: [operation], - budgetVersion: budget.version, - transferConfidenceLevel: undefined, - taggingMode: 'Skip' - }; - - this.operationsApi.updateOperations(this.budgetId, request).subscribe({ - next: (result) => { - this.isLoading = false; - - if (result.errors && result.errors.length > 0) { - const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); - this.showError(`Failed to update operation: ${errorMessage}`); - } else { - this.showSuccess('Operation updated successfully'); - this.loadOperations(); - } - }, - error: (error: any) => { - this.isLoading = false; - this.handleError(error, 'Failed to update operation'); - } - }); }, - error: (error: any) => { + error: (error) => { this.isLoading = false; - this.handleError(error, 'Failed to load budget'); + const errorMessage = this.notificationService.handleError(error, 'Failed to update operation'); + this.notificationService.showError(errorMessage).subscribe(); } }); } - - private handleError(error: any, defaultMessage: string): void { - let errorMessage = defaultMessage; - - if (error.status === 400 && Array.isArray(error.error)) { - const errors = error.error as any[]; - errorMessage = errors.map(e => e.message || e).join('; '); - } else if (error.error?.message) { - errorMessage = error.error.message; - } - - this.showError(errorMessage); - } - - private showError(message: string): void { - this.dialogService.open(message, { - label: 'Error', - size: 'm', - closeable: true, - dismissible: true - }).subscribe(); - } - - private showSuccess(message: string): void { - this.dialogService.open(message, { - label: 'Success', - size: 's', - closeable: true, - dismissible: true - }).subscribe(); - } - - // Helper method for template to access Object.keys - getObjectKeys(obj: any): string[] { - return obj ? Object.keys(obj) : []; - } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html index 3d9e493f..0d5254e3 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.html @@ -15,7 +15,7 @@ @for (operation of operations; track operation.id) { - {{ formatDate(operation.timestamp) }} + {{ operation.timestamp | dateFormat }} @if (isEditing(operation.id) && editingOperation) {
} @else { - {{ formatCurrency(operation.amount.value, operation.amount.currencyCode) }} + {{ operation.amount.value | currencyFormat: operation.amount.currencyCode }} } @@ -87,7 +87,7 @@ @if (isEditing(operation.id) && editingOperation) {
- @for (key of getObjectKeys(editingOperation.attributes); track key) { + @for (key of editingOperation.attributes | objectKeys; track key) {
} @else { - @if (operation.attributes && getObjectKeys(operation.attributes).length > 0) { + @if (operation.attributes && (operation.attributes | objectKeys).length > 0) {
- @for (key of getObjectKeys(operation.attributes!); track key) { + @for (key of operation.attributes | objectKeys; track key) { {{ key }}: {{ operation.attributes![key] }} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less index 42bc35cb..8518a037 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.less @@ -1,3 +1,5 @@ +@import '../shared/styles/mixins.less'; + .operations-table-container { overflow-x: auto; border-radius: 0.5rem; @@ -121,20 +123,7 @@ gap: 0.25rem; max-height: 60px; overflow-y: auto; - - &::-webkit-scrollbar { - width: 4px; - height: 4px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: var(--tui-base-05); - border-radius: 2px; - } + .custom-scrollbar(); } } @@ -147,20 +136,7 @@ gap: 0.25rem; max-height: 60px; overflow-y: auto; - - &::-webkit-scrollbar { - width: 4px; - height: 4px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: var(--tui-base-05); - border-radius: 2px; - } + .custom-scrollbar(); } .attr-chip { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts index 5382994a..046e7ffe 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-table/operations-table.component.ts @@ -4,6 +4,9 @@ import { FormsModule } from '@angular/forms'; import { OperationResponse } from '../../budget/models'; import { TuiButton, TuiExpand, TuiTextfield } from '@taiga-ui/core'; import { TuiChip } from '@taiga-ui/kit'; +import { CurrencyFormatPipe } from '../shared/pipes/currency-format.pipe'; +import { DateFormatPipe } from '../shared/pipes/date-format.pipe'; +import { ObjectKeysPipe } from '../shared/pipes/object-keys.pipe'; interface EditableOperation { id: string; @@ -23,7 +26,10 @@ interface EditableOperation { TuiButton, TuiExpand, TuiChip, - TuiTextfield + TuiTextfield, + CurrencyFormatPipe, + DateFormatPipe, + ObjectKeysPipe ], templateUrl: './operations-table.component.html', styleUrls: ['./operations-table.component.less'] @@ -42,27 +48,6 @@ export class OperationsTableComponent { this.expandedOperationId = this.expandedOperationId === operationId ? null : operationId; } - formatCurrency(amount: number, currencyCode: string): string { - // Format number with space as thousand separator - const formattedAmount = amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); - return `${formattedAmount} ${currencyCode}`; - } - - formatDate(timestamp: string): string { - const date = new Date(timestamp); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${year}.${month}.${day}, ${hours}:${minutes}:${seconds}`; - } - - getObjectKeys(obj: any): string[] { - return obj ? Object.keys(obj) : []; - } - deleteOperation(operation: OperationResponse): void { this.operationDeleted.emit(operation); } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.html new file mode 100644 index 00000000..ceacba45 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.html @@ -0,0 +1,40 @@ +
+
+ + + + +
+ + @if (examples.length > 0) { + + + } + +
+ + +
+
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.less new file mode 100644 index 00000000..52efa980 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.less @@ -0,0 +1,16 @@ +@import '../../styles/mixins.less'; + +.criteria-filter { + .filters-section-base(); +} + +.filter-field { + margin-bottom: 1rem; +} + +.filter-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.ts new file mode 100644 index 00000000..a790e309 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.ts @@ -0,0 +1,53 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { TuiButton, TuiTextfield, TuiLabel } from '@taiga-ui/core'; +import { TuiTextarea } from '@taiga-ui/kit'; +import { CriteriaExample } from '../../models/example.interface'; +import { ExamplesSectionComponent } from '../examples-section/examples-section.component'; +import { CtrlEnterDirective } from '../../directives/ctrl-enter.directive'; + +@Component({ + selector: 'app-criteria-filter', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TuiButton, + TuiTextfield, + TuiLabel, + TuiTextarea, + ExamplesSectionComponent, + CtrlEnterDirective + ], + templateUrl: './criteria-filter.component.html', + styleUrls: ['./criteria-filter.component.less'] +}) +export class CriteriaFilterComponent implements OnInit { + @Input() initialCriteria = 'o => true'; + @Input() examples: CriteriaExample[] = []; + @Input() showExamplesInitially = false; + @Output() criteriaSubmitted = new EventEmitter(); + @Output() criteriaCleared = new EventEmitter(); + + filterForm!: FormGroup; + + constructor(private fb: FormBuilder) {} + + ngOnInit(): void { + this.filterForm = this.fb.group({ + criteria: [this.initialCriteria] + }); + } + + apply(): void { + const criteria = this.filterForm.value.criteria; + this.criteriaSubmitted.emit(criteria); + } + + clear(): void { + this.filterForm.patchValue({ criteria: this.initialCriteria }); + this.criteriaCleared.emit(); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.html new file mode 100644 index 00000000..36b09e05 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.html @@ -0,0 +1,25 @@ +
+
+

{{ title }}

+ +
+ + @if (expanded) { +
+ @for (example of examples; track example.code) { +
+ {{ example.label }} + {{ example.code }} +
+ } +
+ } +
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.less new file mode 100644 index 00000000..c27e301c --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.less @@ -0,0 +1,6 @@ +@import '../../styles/mixins.less'; + +.examples-section { + .examples-section-base(); +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.ts new file mode 100644 index 00000000..f923db29 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/examples-section/examples-section.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TuiButton } from '@taiga-ui/core'; +import { CriteriaExample } from '../../models/example.interface'; + +@Component({ + selector: 'app-examples-section', + standalone: true, + imports: [CommonModule, TuiButton], + templateUrl: './examples-section.component.html', + styleUrls: ['./examples-section.component.less'] +}) +export class ExamplesSectionComponent { + @Input() examples: CriteriaExample[] = []; + @Input() title = 'Common Examples'; + @Input() expanded = false; + + toggle(): void { + this.expanded = !this.expanded; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.html new file mode 100644 index 00000000..d3475fed --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.html @@ -0,0 +1,12 @@ +@if (metadata && (metadata | objectKeys).length > 0) { + +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.less new file mode 100644 index 00000000..904f772d --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.less @@ -0,0 +1,6 @@ +@import '../../styles/mixins.less'; + +.metadata { + .metadata-base(); +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.ts new file mode 100644 index 00000000..619f8788 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/metadata-display/metadata-display.component.ts @@ -0,0 +1,16 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ObjectKeysPipe } from '../../pipes/object-keys.pipe'; + +@Component({ + selector: 'app-metadata-display', + standalone: true, + imports: [CommonModule, ObjectKeysPipe], + templateUrl: './metadata-display.component.html', + styleUrls: ['./metadata-display.component.less'] +}) +export class MetadataDisplayComponent { + @Input() metadata: Record | null | undefined = null; + @Input() title = 'Details'; +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.html new file mode 100644 index 00000000..fa27147d --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.html @@ -0,0 +1,60 @@ + +@if (successes.length > 0) { +
+
+

Success Messages ({{ successes.length }})

+ +
+ @if (showSuccesses) { + @for (success of successes; track $index) { +
+
{{ success.message || 'Success' }}
+ +
+ } + } +
+} + + +@if (errors.length > 0) { +
+
+

Errors ({{ errors.length }})

+ +
+ @if (showErrors) { + @for (error of errors; track $index) { +
+
{{ error.message || 'Error' }}
+ + @if (error.reasons && error.reasons.length > 0) { +
+ Caused by: +
    + @for (reason of error.reasons; track $index) { +
  • {{ reason.message || 'Unspecified reason' }}
  • + } +
+
+ } +
+ } + } +
+} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.less new file mode 100644 index 00000000..f484b396 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.less @@ -0,0 +1,34 @@ +@import '../../styles/mixins.less'; + +.section-header-with-toggle { + .section-header-with-toggle(); +} + +.successes-section { + margin-top: 1.5rem; + + h4 { + color: var(--tui-success-fill); + } +} + +.success-item { + .success-item-base(); +} + +.errors-section { + margin-top: 1.5rem; + + h4 { + color: var(--tui-error-fill); + } +} + +.error-item { + .error-item-base(); +} + +.nested-reasons { + .nested-reasons-base(); +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.ts new file mode 100644 index 00000000..e2fe5697 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/operation-result/operation-result.component.ts @@ -0,0 +1,29 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TuiButton } from '@taiga-ui/core'; +import { IError, ISuccess } from '../../../../budget/models'; +import { MetadataDisplayComponent } from '../metadata-display/metadata-display.component'; + +@Component({ + selector: 'app-operation-result', + standalone: true, + imports: [CommonModule, TuiButton, MetadataDisplayComponent], + templateUrl: './operation-result.component.html', + styleUrls: ['./operation-result.component.less'] +}) +export class OperationResultComponent { + @Input() successes: ISuccess[] = []; + @Input() errors: IError[] = []; + + showSuccesses = true; + showErrors = true; + + toggleSuccesses(): void { + this.showSuccesses = !this.showSuccesses; + } + + toggleErrors(): void { + this.showErrors = !this.showErrors; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/directives/ctrl-enter.directive.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/directives/ctrl-enter.directive.ts new file mode 100644 index 00000000..0be02046 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/directives/ctrl-enter.directive.ts @@ -0,0 +1,18 @@ +import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; + +@Directive({ + selector: '[appCtrlEnter]', + standalone: true +}) +export class CtrlEnterDirective { + @Output() appCtrlEnter = new EventEmitter(); + + @HostListener('keydown', ['$event']) + onKeyDown(event: KeyboardEvent): void { + if (event.key === 'Enter' && event.ctrlKey) { + event.preventDefault(); + this.appCtrlEnter.emit(); + } + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/models/example.interface.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/models/example.interface.ts new file mode 100644 index 00000000..2ab0120e --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/models/example.interface.ts @@ -0,0 +1,5 @@ +export interface CriteriaExample { + label: string; + code: string; +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/models/result.interface.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/models/result.interface.ts new file mode 100644 index 00000000..ab50580a --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/models/result.interface.ts @@ -0,0 +1,13 @@ +import { IError, ISuccess } from '../../../budget/models'; + +export interface OperationResult { + errors: IError[]; + successes: ISuccess[]; +} + +export interface ImportResult extends OperationResult { + registered: number; + duplicates: number; + duplicatesList?: any[]; +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/notification.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/notification.service.ts new file mode 100644 index 00000000..a7d0239d --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/notification.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import { TuiDialogService } from '@taiga-ui/core'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class NotificationService { + constructor(private dialogService: TuiDialogService) {} + + showError(message: string): Observable { + return this.dialogService.open(message, { + label: 'Error', + size: 'm', + closeable: true, + dismissible: true + }); + } + + showSuccess(message: string): Observable { + return this.dialogService.open(message, { + label: 'Success', + size: 's', + closeable: true, + dismissible: true + }); + } + + showWarning(message: string): Observable { + return this.dialogService.open(message, { + label: 'Warning', + size: 'm', + closeable: true, + dismissible: true + }); + } + + confirm(message: string, title: string = 'Confirm'): Observable { + return new Observable(observer => { + this.dialogService.open(message, { + label: title, + size: 'm', + closeable: true, + dismissible: true + }).subscribe({ + next: () => { + observer.next(true); + observer.complete(); + }, + error: () => { + observer.next(false); + observer.complete(); + } + }); + }); + } + + handleError(error: any, defaultMessage: string): string { + let errorMessage = defaultMessage; + + if (error.status === 400 && Array.isArray(error.error)) { + const errors = error.error as any[]; + errorMessage = errors.map(e => e.message || e).join('; '); + } else if (error.error?.message) { + errorMessage = error.error.message; + } + + return errorMessage; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/operations-helper.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/operations-helper.service.ts new file mode 100644 index 00000000..f8bde58f --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/operations-helper.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@angular/core'; +import { Observable, switchMap, of, throwError } from 'rxjs'; +import { OperationsApiService } from '../operations-api.service'; +import { BudgetApiService } from '../../budget/budget-api.service'; +import { OperationResponse, BudgetResponse, UpdateResultResponse } from '../../budget/models'; + +@Injectable({ + providedIn: 'root' +}) +export class OperationsHelperService { + constructor( + private operationsApi: OperationsApiService, + private budgetApi: BudgetApiService + ) {} + + /** + * Delete a single operation by ID + */ + deleteOperation(budgetId: string, operationId: string): Observable { + const criteria = `o => o.Id == Guid.Parse("${operationId}")`; + return this.operationsApi.removeOperations(budgetId, { criteria }); + } + + /** + * Update a single operation + */ + updateOperation(budgetId: string, operation: OperationResponse): Observable { + return this.budgetApi.getAllBudgets().pipe( + switchMap((budgetList: BudgetResponse[]) => { + const budget = budgetList.find((b: BudgetResponse) => b.id === budgetId); + + if (!budget) { + return throwError(() => new Error('Budget not found')); + } + + const request = { + operations: [operation], + budgetVersion: budget.version, + transferConfidenceLevel: undefined, + taggingMode: 'Skip' + }; + + return this.operationsApi.updateOperations(budgetId, request); + }) + ); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/currency-format.pipe.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/currency-format.pipe.ts new file mode 100644 index 00000000..7d35cc50 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/currency-format.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'currencyFormat', + standalone: true +}) +export class CurrencyFormatPipe implements PipeTransform { + transform(amount: number, currencyCode: string): string { + // Format number with space as thousand separator + const formattedAmount = amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); + return `${formattedAmount} ${currencyCode}`; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/date-format.pipe.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/date-format.pipe.ts new file mode 100644 index 00000000..7cfb5459 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/date-format.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'dateFormat', + standalone: true +}) +export class DateFormatPipe implements PipeTransform { + transform(timestamp: string): string { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}.${month}.${day}, ${hours}:${minutes}:${seconds}`; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/object-keys.pipe.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/object-keys.pipe.ts new file mode 100644 index 00000000..51bff463 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/pipes/object-keys.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'objectKeys', + standalone: true +}) +export class ObjectKeysPipe implements PipeTransform { + transform(obj: Record | null | undefined): string[] { + return obj ? Object.keys(obj) : []; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/styles/mixins.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/styles/mixins.less new file mode 100644 index 00000000..ada95902 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/styles/mixins.less @@ -0,0 +1,252 @@ +// Custom scrollbar mixin +.custom-scrollbar() { + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--tui-base-05); + border-radius: 2px; + } +} + +// Page container mixin +.page-container() { + padding: 2rem; + margin: 0 auto; +} + +// Card styling +.card-base() { + padding: 2rem; +} + +// Header with actions +.header-with-actions() { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; + + .header-actions { + display: flex; + gap: 1rem; + } +} + +// Filter section +.filters-section-base() { + margin-bottom: 1.5rem; + padding: 1.5rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + + h3 { + margin-bottom: 1rem; + } +} + +// Examples section +.examples-section-base() { + margin: 1rem 0; + padding: 1rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + + .examples-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + + h4 { + margin: 0; + color: var(--tui-text-02); + } + } + + .examples-list { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .example-item { + padding: 0.75rem; + background: var(--tui-base-02); + border-radius: 0.25rem; + + strong { + display: block; + margin-bottom: 0.25rem; + color: var(--tui-text-02); + font-size: 0.875rem; + } + + code { + display: block; + background: var(--tui-base-03); + padding: 0.5rem; + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.875rem; + color: var(--tui-text-01); + } + } +} + +// Section header with toggle +.section-header-with-toggle() { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + + h4 { + margin: 0; + } +} + +// Result display +.result-section-base() { + margin-top: 2rem; + padding: 1.5rem; + background: var(--tui-base-02); + border-radius: 0.5rem; +} + +// Result stats +.result-stats-base() { + display: flex; + gap: 2rem; + margin: 1rem 0; + flex-wrap: wrap; + + .stat-item { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .stat-label { + font-size: 0.875rem; + color: var(--tui-text-02); + } + + .stat-value { + font-size: 1.5rem; + font-weight: 700; + } + + &.success .stat-value { + color: var(--tui-success-fill); + } + + &.warning .stat-value { + color: var(--tui-warning-fill); + } + + &.error .stat-value { + color: var(--tui-error-fill); + } + + &.info .stat-value { + color: var(--tui-primary); + } + } +} + +// Success/Error items +.success-item-base() { + margin-bottom: 1rem; + padding: 1rem; + background: var(--tui-success-bg); + border-left: 3px solid var(--tui-success-fill); + border-radius: 0.25rem; + + .success-message { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--tui-text-01); + } +} + +.error-item-base() { + margin-bottom: 1rem; + padding: 1rem; + background: var(--tui-error-bg); + border-left: 3px solid var(--tui-error-fill); + border-radius: 0.25rem; + + .error-message { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--tui-text-01); + } +} + +// Metadata display +.metadata-base() { + margin-top: 0.75rem; + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + font-size: 0.875rem; + + strong { + display: block; + margin-bottom: 0.5rem; + color: var(--tui-text-02); + } + + .metadata-item { + display: flex; + gap: 0.5rem; + margin: 0.25rem 0; + + .metadata-key { + font-weight: 600; + color: var(--tui-text-02); + min-width: 120px; + } + + .metadata-value { + color: var(--tui-text-01); + font-family: monospace; + } + } +} + +// Nested reasons +.nested-reasons-base() { + margin-top: 0.75rem; + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + font-size: 0.875rem; + + strong { + display: block; + margin-bottom: 0.5rem; + color: var(--tui-text-02); + } + + ul { + margin: 0; + padding-left: 1.5rem; + + li { + margin: 0.25rem 0; + } + } +} + From d2ad2ffcc4f3a75061b6f7e02712140a3d4ac472 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Mon, 20 Oct 2025 21:17:39 +0300 Subject: [PATCH 125/156] feat: Add retag operations feature and improve criteria-filter component Backend: - Add RetagOperationsRequest and RetagResultResponse models - Add RetagOperations endpoint in OperationsController (POST /operations/retag) - Support 'fromScratch' mode to remove all tags before retagging - Integrate with RetagOperationsCommand for business logic Frontend: - Create retag-operations component with full UI - Add retag operations route and navigation from operations list - Support criteria expression with examples - Add 'from scratch' checkbox option for complete re-tagging - Display success/error results with statistics Improvements: - Add ng-content slot to criteria-filter component for extensibility - Refactor operations-list to use shared criteria-filter component - Add criteria examples to operations-list for better UX - Replace ReactiveForm with FormsModule for simpler state management - Improve styling consistency across filter components --- .../Controllers/OperationsController.cs | 48 ++++++ .../Models/OperationResponse.cs | 11 ++ .../budget-client/src/app/app.routes.ts | 2 + .../budget-client/src/app/budget/models.ts | 11 ++ .../app/operations/operations-api.service.ts | 16 +- .../operations-list.component.html | 96 +++++------- .../operations-list.component.less | 39 +++-- .../operations-list.component.ts | 64 ++++---- .../retag-operations.component.html | 72 +++++++++ .../retag-operations.component.less | 71 +++++++++ .../retag-operations.component.ts | 143 ++++++++++++++++++ .../criteria-filter.component.html | 2 + .../criteria-filter.component.less | 2 +- 13 files changed, 469 insertions(+), 108 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.html create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.less create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.ts diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs index b0e3e3da..ae9f5e92 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Controllers/OperationsController.cs @@ -415,4 +415,52 @@ public async Task RemoveOperations( return BadRequest(result.Errors); } + + /// + /// Retags operations matching the specified criteria in a specific budget + /// + /// Budget ID from route + /// Retag operations request with criteria expression and options + /// Cancellation token + /// Retag result with success/failure details + [HttpPost("retag")] + [Consumes("application/json", "application/yaml", "text/yaml")] + [ProducesResponseType(typeof(RetagResultResponse), 200)] + [ProducesResponseType(typeof(IEnumerable), 400)] + [ProducesResponseType(typeof(IEnumerable), 404)] + public async Task RetagOperations( + [FromRoute] Guid budgetId, + [FromBody] RetagOperationsRequest request, + CancellationToken ct) + { + // Validate budget access + var budgets = await mediator.Send(new ListOwnedBudgetsQuery(), ct); + var budget = budgets.FirstOrDefault(b => b.Id == budgetId); + + if (budget == null) + { + return NotFound(new List { new($"Budget with ID {budgetId} not found or access denied") }); + } + + // Update budget version for optimistic concurrency + budget.Version = request.BudgetVersion; + + // Parse criteria expression + var criteriaResult = parser.ParseUnaryPredicate(request.Criteria); + if (criteriaResult.IsFailed) + { + return BadRequest(criteriaResult.Errors); + } + + var command = new RetagOperationsCommand(criteriaResult.Value.AsExpression(), budget, request.FromScratch); + var result = await mediator.Send(command, ct); + + if (result.IsSuccess) + { + var response = new RetagResultResponse(result.Errors, result.Successes); + return Ok(response); + } + + return BadRequest(result.Errors); + } } diff --git a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs index 8869e02e..815db81b 100644 --- a/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs +++ b/src/Controllers/NVs.Budget.Controllers.Web/Models/OperationResponse.cs @@ -46,6 +46,12 @@ public record RemoveOperationsRequest( string Criteria ); +public record RetagOperationsRequest( + string BudgetVersion, + string Criteria, + bool FromScratch +); + // Result response models public record ImportResultResponse( IReadOnlyCollection RegisteredOperations, @@ -64,3 +70,8 @@ public record DeleteResultResponse( IReadOnlyCollection Successes ); +public record RetagResultResponse( + IReadOnlyCollection Errors, + IReadOnlyCollection Successes +); + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts index a52beb5d..c33afa32 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/app.routes.ts @@ -5,6 +5,7 @@ import { ReadingSettingsComponent } from './budget/reading-settings/reading-sett import { ImportOperationsComponent } from './operations/import-operations/import-operations.component'; import { OperationsListComponent } from './operations/operations-list/operations-list.component'; import { DeleteOperationsComponent } from './operations/delete-operations/delete-operations.component'; +import { RetagOperationsComponent } from './operations/retag-operations/retag-operations.component'; import { DuplicatesListComponent } from './operations/duplicates-list/duplicates-list.component'; import { IndexComponent } from './index/index.component'; @@ -12,6 +13,7 @@ export const routes: Routes = [ { path: 'budget/new', component: NewBudgetComponent }, { path: 'budget/:budgetId/operations/import', component: ImportOperationsComponent }, { path: 'budget/:budgetId/operations/delete', component: DeleteOperationsComponent }, + { path: 'budget/:budgetId/operations/retag', component: RetagOperationsComponent }, { path: 'budget/:budgetId/operations/duplicates', component: DuplicatesListComponent }, { path: 'budget/:budgetId/operations', component: OperationsListComponent }, { path: 'budget/:budgetId/details', component: BudgetDetailComponent }, diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts index acbaf828..a6f17a70 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/budget/models.ts @@ -117,6 +117,12 @@ export interface RemoveOperationsRequest { criteria: string; } +export interface RetagOperationsRequest { + budgetVersion: string; + criteria: string; + fromScratch: boolean; +} + export interface IReason { message?: string; metadata?: Record; @@ -143,4 +149,9 @@ export interface UpdateResultResponse { export interface DeleteResultResponse { errors: IError[]; successes: ISuccess[]; +} + +export interface RetagResultResponse { + errors: IError[]; + successes: ISuccess[]; } \ No newline at end of file diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts index faab4611..8769bd49 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts @@ -5,9 +5,11 @@ import { OperationResponse, UpdateOperationsRequest, RemoveOperationsRequest, + RetagOperationsRequest, ImportResultResponse, UpdateResultResponse, - DeleteResultResponse + DeleteResultResponse, + RetagResultResponse } from '../budget/models'; import { environment } from '../../environments/environment'; @@ -110,6 +112,18 @@ export class OperationsApiService { ); } + /** + * Retag operations matching criteria + */ + retagOperations(budgetId: string, request: RetagOperationsRequest): Observable { + const headers = new HttpHeaders().set('Content-Type', 'application/json'); + return this.http.post( + `${this.baseUrl}/budget/${budgetId}/operations/retag`, + request, + { headers, withCredentials: true } + ); + } + /** * Trigger refresh for operations list */ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 9c68541a..1a8004cd 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -19,6 +19,14 @@

Operations

(click)="navigateToDuplicates()"> View Duplicates +
-
+

Filters

-
-
- - - - -
- -
- - - - -
+ -
- +
+
+ + + + +
+ +
+ +
-
- -
- - -
- +
+
diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less index 9675997f..3dbe9bfb 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.less @@ -14,36 +14,35 @@ .filters-section { .filters-section-base(); + + h3 { + margin-bottom: 1rem; + } } -.filters-grid { +.additional-filters { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: 1fr auto; gap: 1rem; - margin-bottom: 1rem; + margin-top: 1rem; + padding: 1rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + align-items: end; .filter-item { - &.full-width { - grid-column: 1 / -1; + display: flex; + flex-direction: column; + + label[tuiLabel] { + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; } } } -.checkbox-field { - display: flex; - align-items: center; - padding-top: 1.5rem; - - label { - cursor: pointer; - } -} - -.filter-actions { - display: flex; - gap: 1rem; -} - .operations-section { margin-top: 2rem; } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts index 66626273..47e4e4e6 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { Observable, catchError, of } from 'rxjs'; import { OperationsApiService } from '../operations-api.service'; @@ -10,34 +10,31 @@ import { TuiLoader, TuiTitle, TuiTextfield, - TuiLabel, - TuiExpand + TuiLabel } from '@taiga-ui/core'; import { TuiCardLarge } from '@taiga-ui/layout'; -import { TuiAccordion, TuiTextarea, TuiCheckbox } from '@taiga-ui/kit'; +import { TuiCheckbox } from '@taiga-ui/kit'; import { OperationsTableComponent } from '../operations-table/operations-table.component'; import { NotificationService } from '../shared/notification.service'; import { OperationsHelperService } from '../shared/operations-helper.service'; -import { CtrlEnterDirective } from '../shared/directives/ctrl-enter.directive'; +import { CriteriaFilterComponent } from '../shared/components/criteria-filter/criteria-filter.component'; +import { CriteriaExample } from '../shared/models/example.interface'; @Component({ selector: 'app-operations-list', standalone: true, imports: [ CommonModule, - ReactiveFormsModule, + FormsModule, TuiButton, TuiLoader, TuiTextfield, TuiLabel, TuiCardLarge, TuiTitle, - TuiAccordion, - TuiExpand, - TuiTextarea, TuiCheckbox, OperationsTableComponent, - CtrlEnterDirective + CriteriaFilterComponent ], templateUrl: './operations-list.component.html', styleUrls: ['./operations-list.component.less'] @@ -48,36 +45,40 @@ export class OperationsListComponent implements OnInit { operations: OperationResponse[] = []; isLoading = false; - filterForm!: FormGroup; + currentCriteria = ''; + outputCurrency = ''; + excludeTransfers = false; + + criteriaExamples: CriteriaExample[] = [ + { label: 'All operations:', code: 'o => true' }, + { label: 'Positive amounts:', code: 'o => o.Amount.Amount > 0' }, + { label: 'Negative amounts:', code: 'o => o.Amount.Amount < 0' }, + { label: 'Specific year:', code: 'o => o.Timestamp.Year == 2023' }, + { label: 'Contains text:', code: 'o => o.Description.Contains("groceries")' }, + { label: 'By tag:', code: 'o => o.Tags.Any(t => t.Value == "food")' }, + { label: 'Without tags:', code: 'o => o.Tags.Count == 0' }, + { label: 'Amount range:', code: 'o => o.Amount.Amount >= -1000 && o.Amount.Amount <= -100' } + ]; constructor( private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private fb: FormBuilder, private notificationService: NotificationService, private operationsHelper: OperationsHelperService ) {} ngOnInit(): void { this.budgetId = this.route.snapshot.params['budgetId']; - - this.filterForm = this.fb.group({ - criteria: [''], - outputCurrency: [''], - excludeTransfers: [false] - }); - this.loadOperations(); } loadOperations(): void { - const formValue = this.filterForm.value; this.operations$ = this.operationsApi.getOperations( this.budgetId, - formValue.criteria || undefined, - formValue.outputCurrency || undefined, - formValue.excludeTransfers + this.currentCriteria || undefined, + this.outputCurrency || undefined, + this.excludeTransfers ).pipe( catchError(error => { const errorMessage = this.notificationService.handleError(error, 'Failed to load operations'); @@ -90,16 +91,15 @@ export class OperationsListComponent implements OnInit { this.operations$.subscribe(ops => this.operations = ops); } - applyFilters(): void { + onCriteriaSubmitted(criteria: string): void { + this.currentCriteria = criteria; this.loadOperations(); } - clearFilters(): void { - this.filterForm.reset({ - criteria: '', - outputCurrency: '', - excludeTransfers: false - }); + onCriteriaCleared(): void { + this.currentCriteria = ''; + this.outputCurrency = ''; + this.excludeTransfers = false; this.loadOperations(); } @@ -119,6 +119,10 @@ export class OperationsListComponent implements OnInit { this.router.navigate(['/budget', this.budgetId, 'operations', 'duplicates']); } + navigateToRetag(): void { + this.router.navigate(['/budget', this.budgetId, 'operations', 'retag']); + } + onDeleteOperation(operation: OperationResponse): void { const confirmMessage = `Are you sure you want to delete this operation?\n\n${operation.description}\n${operation.amount.value} ${operation.amount.currencyCode}\n\nThis action cannot be undone.`; diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.html new file mode 100644 index 00000000..9a0fbdbe --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.html @@ -0,0 +1,72 @@ +
+ +
+

Retag Operations

+ + + +
+ +
+
+ +
+ + @if (retagResult) { + + } +
+ + @if (retagResult) { +
+

Retagging Results

+
+ @if (retagResult.successes.length > 0) { +
+ Successes: + {{ retagResult.successes.length }} +
+ } + @if (retagResult.errors.length > 0) { +
+ Errors: + {{ retagResult.errors.length }} +
+ } +
+ + + +
+ } +
+
+
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.less new file mode 100644 index 00000000..8ec1dcda --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.less @@ -0,0 +1,71 @@ +@import '../shared/styles/mixins.less'; + +.retag-container { + .page-container(); +} + +.retag-card { + .card-base(); +} + +.info-warning { + margin: 1.5rem 0; + padding: 1.5rem; + background: var(--tui-info-bg); + border: 2px solid var(--tui-info-fill); + border-radius: 0.5rem; + + h3 { + margin: 0 0 1rem 0; + color: var(--tui-info-fill); + font-size: 1.125rem; + } + + p { + margin: 0.5rem 0; + color: var(--tui-text-01); + line-height: 1.5; + } + + ul { + margin: 0.75rem 0; + padding-left: 1.5rem; + + li { + margin: 0.5rem 0; + line-height: 1.5; + + strong { + font-weight: 600; + } + } + } +} + +.retag-options { + margin: 1.5rem 0; + padding: 1rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + + .from-scratch-checkbox { + font-size: 1rem; + font-weight: 500; + } +} + +.action-buttons { + display: flex; + gap: 1rem; + margin: 2rem 0; + flex-wrap: wrap; +} + +.retag-result { + .result-section-base(); +} + +.result-stats { + .result-stats-base(); +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.ts new file mode 100644 index 00000000..d3e2c9ad --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/retag-operations/retag-operations.component.ts @@ -0,0 +1,143 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { OperationsApiService } from '../operations-api.service'; +import { BudgetApiService } from '../../budget/budget-api.service'; +import { + TuiButton, + TuiLoader, + TuiTitle, + TuiLabel +} from '@taiga-ui/core'; +import { TuiCardLarge } from '@taiga-ui/layout'; +import { TuiCheckbox } from '@taiga-ui/kit'; +import { FormsModule } from '@angular/forms'; +import { NotificationService } from '../shared/notification.service'; +import { CriteriaFilterComponent } from '../shared/components/criteria-filter/criteria-filter.component'; +import { OperationResultComponent } from '../shared/components/operation-result/operation-result.component'; +import { CriteriaExample } from '../shared/models/example.interface'; +import { OperationResult } from '../shared/models/result.interface'; + +@Component({ + selector: 'app-retag-operations', + standalone: true, + imports: [ + CommonModule, + FormsModule, + TuiButton, + TuiLoader, + TuiCardLarge, + TuiTitle, + TuiLabel, + TuiCheckbox, + CriteriaFilterComponent, + OperationResultComponent + ], + templateUrl: './retag-operations.component.html', + styleUrls: ['./retag-operations.component.less'] +}) +export class RetagOperationsComponent implements OnInit { + budgetId!: string; + budgetVersion!: string; + isLoading = false; + retagResult: OperationResult | null = null; + currentCriteria = 'o => true'; + fromScratch = false; + + criteriaExamples: CriteriaExample[] = [ + { label: 'All operations:', code: 'o => true' }, + { label: 'Specific year:', code: 'o => o.Timestamp.Year == 2023' }, + { label: 'Contains text:', code: 'o => o.Description.Contains("groceries")' }, + { label: 'Missing tags:', code: 'o => o.Tags.Count == 0' }, + { label: 'Date range:', code: 'o => o.Timestamp >= DateTime.Parse("2023-01-01") && o.Timestamp < DateTime.Parse("2024-01-01")' }, + { label: 'By amount range:', code: 'o => o.Amount.Amount >= -1000 && o.Amount.Amount <= -100' }, + { label: 'Specific attribute:', code: 'o => o.Attributes.ContainsKey("category") && o.Attributes["category"] == "food"' } + ]; + + constructor( + private route: ActivatedRoute, + private router: Router, + private operationsApi: OperationsApiService, + private budgetApi: BudgetApiService, + private notificationService: NotificationService + ) {} + + ngOnInit(): void { + this.budgetId = this.route.snapshot.params['budgetId']; + this.loadBudgetVersion(); + } + + loadBudgetVersion(): void { + this.budgetApi.getAllBudgets().subscribe({ + next: (budgets: any) => { + const budget = budgets.find((b: any) => b.id === this.budgetId); + if (budget) { + this.budgetVersion = budget.version; + } + }, + error: (error: any) => { + const errorMessage = this.notificationService.handleError(error, 'Failed to load budget'); + this.notificationService.showError(errorMessage).subscribe(); + } + }); + } + + retagOperations(criteria: string): void { + this.currentCriteria = criteria; + const action = this.fromScratch ? 'retag from scratch' : 'retag'; + const confirmMessage = `Are you sure you want to ${action} all operations matching the criteria:\n\n${criteria}\n\n${this.fromScratch ? 'This will remove all existing tags and apply tagging criteria from the beginning.' : 'This will apply tagging criteria to operations that match.'}`; + + const confirmed = confirm(confirmMessage); + if (!confirmed) return; + + if (!this.budgetVersion) { + this.notificationService.showError('Budget version not loaded. Please try again.').subscribe(); + return; + } + + this.isLoading = true; + this.retagResult = null; + + const request = { + budgetVersion: this.budgetVersion, + criteria: criteria, + fromScratch: this.fromScratch + }; + + this.operationsApi.retagOperations(this.budgetId, request).subscribe({ + next: (result) => { + this.isLoading = false; + this.retagResult = { + errors: result.errors, + successes: result.successes + }; + + if (result.errors.length === 0) { + this.notificationService.showSuccess('Operations retagged successfully').subscribe(); + this.operationsApi.triggerRefresh(this.budgetId); + // Reload budget version after successful retag + this.loadBudgetVersion(); + } else { + const errorMessage = result.errors.length > 5 + ? `Retagging completed with ${result.errors.length} errors. Check the results below.` + : `Retagging completed with errors. See details below.`; + this.notificationService.showError(errorMessage).subscribe(); + } + }, + error: (error) => { + this.isLoading = false; + const errorMessage = this.notificationService.handleError(error, 'Failed to retag operations'); + this.notificationService.showError(errorMessage).subscribe(); + } + }); + } + + viewOperations(): void { + this.router.navigate(['/budget', this.budgetId]); + } + + resetResult(): void { + this.retagResult = null; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.html index ceacba45..4ef234f7 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.html @@ -19,6 +19,8 @@ } + +
+ + + +
+
+ +
+ +
+ +
+ + @if (logbook) { +
+

Logbook Statistics

+ + @if (logbook.root) { +
+ +
+ } @else { +
+

No logbook data available for the selected criteria and date range.

+
+ } + + @if (logbook.errors.length > 0) { +
+

Errors ({{ logbook.errors.length }})

+
+ @for (error of logbook.errors; track $index) { +
+
+ {{ error.message || 'Unknown error' }} +
+ + @if (hasMetadata(error)) { + + } + + @if (hasNestedReasons(error)) { +
+
Nested Reasons:
+
+ @for (reason of error.reasons; track $index) { +
+ {{ reason.message || 'No message' }} + @if (hasMetadata(reason)) { + + } +
+ } +
+
+ } +
+ } +
+
+ } +
+ } +
+ +
+ + +
+
+ + +
+ + Sum: + + {{ entry.sum.value | currencyFormat: entry.sum.currencyCode }} + + + + Count: + {{ entry.operationsCount }} + + + Period: + {{ entry.from | dateFormat }} - {{ entry.till | dateFormat }} + +
+
+ + @if (isExpanded(getEntryPath(path, entry.description))) { +
+ @if (hasOperations(entry) && !hasChildren(entry)) { +
+
+ {{ areOperationsExpanded(getEntryPath(path, entry.description)) ? '▼' : '▶' }} +

Operations ({{ entry.operations.length }})

+
+ + @if (areOperationsExpanded(getEntryPath(path, entry.description))) { +
+ + +
+ } +
+ } + + @if (hasChildren(entry)) { +
+ @for (child of entry.children; track $index) { + + } +
+ } +
+ } +
+
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.less new file mode 100644 index 00000000..5b387319 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.less @@ -0,0 +1,307 @@ +@import '../shared/styles/mixins.less'; + +.logbook-container { + .page-container(); +} + +.logbook-card { + .card-base(); +} + +.filters-section { + .filters-section-base(); + + h3 { + margin-bottom: 1rem; + } +} + +.date-range-section { + margin-top: 1rem; + padding: 1rem; + background: var(--tui-base-02); + border-radius: 0.5rem; +} + +.date-inputs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.date-field { + display: flex; + flex-direction: column; +} + +.date-presets { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.action-buttons { + display: flex; + gap: 1rem; + margin: 2rem 0; + flex-wrap: wrap; +} + +.logbook-section { + margin-top: 2rem; + + h3 { + margin-bottom: 1.5rem; + } +} + +.logbook-tree { + background: var(--tui-base-01); + border: 1px solid var(--tui-base-04); + border-radius: 0.5rem; + padding: 1rem; +} + +.logbook-entry { + margin: 0.5rem 0; + + .entry-header { + cursor: pointer; + + &:hover { + background: var(--tui-base-03); + } + } +} + +.entry-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--tui-base-02); + border-radius: 0.25rem; + transition: background-color 0.15s; + gap: 1rem; + flex-wrap: wrap; +} + +.entry-info { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: var(--tui-text-01); + min-width: 200px; +} + +.expand-icon { + width: 1rem; + text-align: center; + color: var(--tui-text-02); + font-size: 0.75rem; +} + +.entry-description { + font-size: 1rem; +} + +.entry-stats { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; +} + +.stat-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; +} + +.stat-label { + color: var(--tui-text-02); + font-weight: 500; +} + +.stat-value { + color: var(--tui-text-01); + font-family: monospace; + font-weight: 600; + + &.positive { + color: var(--tui-success-fill); + } + + &.negative { + color: var(--tui-error-fill); + } +} + +.entry-content { + margin-top: 0.5rem; +} + +.entry-operations-section { + margin-bottom: 1rem; +} + +.operations-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--tui-base-02); + border-radius: 0.25rem; + cursor: pointer; + transition: background-color 0.15s; + + &:hover { + background: var(--tui-base-03); + } +} + +.operations-toggle-icon { + width: 1rem; + text-align: center; + color: var(--tui-text-02); + font-size: 0.75rem; +} + +.operations-title { + margin: 0; + font-size: 0.9375rem; + font-weight: 600; + color: var(--tui-text-02); +} + +.entry-operations { + padding: 1rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + margin-top: 0.5rem; +} + +.entry-children { + margin-top: 1rem; + border-left: 2px solid var(--tui-base-04); + padding-left: 0.5rem; +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + color: var(--tui-text-02); + + p { + margin: 0; + font-size: 1.125rem; + } +} + +.logbook-errors { + margin-top: 2rem; + padding: 1.5rem; + background: var(--tui-error-bg); + border: 2px solid var(--tui-error-fill); + border-radius: 0.5rem; + + h4 { + margin: 0 0 1rem 0; + color: var(--tui-error-fill); + } +} + +.error-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.error-item { + padding: 1rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + color: var(--tui-text-01); + font-size: 0.875rem; + border-left: 3px solid var(--tui-error-fill); +} + +.error-message { + margin-bottom: 0.75rem; + font-size: 1rem; + + strong { + color: var(--tui-text-01); + } +} + +.error-metadata, +.nested-reasons { + margin-top: 0.75rem; + padding: 0.75rem; + background: var(--tui-base-02); + border-radius: 0.25rem; +} + +.metadata-label, +.nested-label { + font-weight: 600; + color: var(--tui-text-02); + margin-bottom: 0.5rem; + font-size: 0.8125rem; + text-transform: uppercase; +} + +.metadata-content { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.metadata-item { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.75rem; + padding: 0.5rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + + &.small { + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; + } +} + +.metadata-key { + font-weight: 600; + color: var(--tui-text-02); + white-space: nowrap; +} + +.metadata-value { + font-family: monospace; + color: var(--tui-text-01); + word-break: break-word; + white-space: pre-wrap; +} + +.nested-content { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.nested-reason { + padding: 0.75rem; + background: var(--tui-base-01); + border-radius: 0.25rem; + border-left: 2px solid var(--tui-base-04); +} + +.reason-metadata { + margin-top: 0.5rem; + padding-left: 0.5rem; +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts new file mode 100644 index 00000000..9251c356 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts @@ -0,0 +1,283 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { OperationsApiService } from '../operations-api.service'; +import { + TuiButton, + TuiLoader, + TuiTitle, + TuiTextfield, + TuiLabel +} from '@taiga-ui/core'; +import { TuiCardLarge } from '@taiga-ui/layout'; +import { TuiAccordion } from '@taiga-ui/kit'; +import { NotificationService } from '../shared/notification.service'; +import { OperationsHelperService } from '../shared/operations-helper.service'; +import { CriteriaFilterComponent } from '../shared/components/criteria-filter/criteria-filter.component'; +import { OperationsTableComponent } from '../operations-table/operations-table.component'; +import { CriteriaExample } from '../shared/models/example.interface'; +import { LogbookEntryResponse, LogbookResponse, OperationResponse } from '../../budget/models'; +import { DateFormatPipe } from '../shared/pipes/date-format.pipe'; +import { CurrencyFormatPipe } from '../shared/pipes/currency-format.pipe'; + +@Component({ + selector: 'app-logbook-view', + standalone: true, + imports: [ + CommonModule, + FormsModule, + TuiButton, + TuiLoader, + TuiCardLarge, + TuiTitle, + TuiTextfield, + TuiLabel, + TuiAccordion, + CriteriaFilterComponent, + OperationsTableComponent, + DateFormatPipe, + CurrencyFormatPipe + ], + templateUrl: './logbook-view.component.html', + styleUrls: ['./logbook-view.component.less'] +}) +export class LogbookViewComponent implements OnInit { + budgetId!: string; + isLoading = false; + logbook: LogbookResponse | null = null; + + currentCriteria = ''; + fromDate: string = ''; + tillDate: string = ''; + + expandedEntries = new Set(); + expandedOperations = new Set(); + + criteriaExamples: CriteriaExample[] = [ + { label: 'All operations:', code: 'o => true' }, + { label: 'Positive amounts:', code: 'o => o.Amount.Amount > 0' }, + { label: 'Negative amounts:', code: 'o => o.Amount.Amount < 0' }, + { label: 'Contains text:', code: 'o => o.Description.Contains("groceries")' }, + { label: 'By tag:', code: 'o => o.Tags.Any(t => t.Value == "food")' }, + { label: 'Amount range:', code: 'o => o.Amount.Amount >= -1000 && o.Amount.Amount <= -100' } + ]; + + constructor( + private route: ActivatedRoute, + private router: Router, + private operationsApi: OperationsApiService, + private notificationService: NotificationService, + private operationsHelper: OperationsHelperService + ) { + this.resetToDefaultDateRange(); + } + + ngOnInit(): void { + this.budgetId = this.route.snapshot.params['budgetId']; + this.loadLogbook(); + } + + onCriteriaSubmitted(criteria: string): void { + this.currentCriteria = criteria; + this.loadLogbook(); + } + + onCriteriaCleared(): void { + this.currentCriteria = ''; + this.resetToDefaultDateRange(); + this.loadLogbook(); + } + + private resetToDefaultDateRange(): void { + const now = new Date(); + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1); + const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); + + this.fromDate = firstDay.toISOString().slice(0, 16); + this.tillDate = lastDay.toISOString().slice(0, 16); + } + + loadLogbook(): void { + this.isLoading = true; + this.logbook = null; + this.expandedEntries.clear(); + this.expandedOperations.clear(); + + const from = this.fromDate ? new Date(this.fromDate) : undefined; + const till = this.tillDate ? new Date(this.tillDate) : undefined; + + this.operationsApi.getLogbook( + this.budgetId, + from, + till, + this.currentCriteria || undefined + ).subscribe({ + next: (result) => { + this.isLoading = false; + this.logbook = result; + + if (result.errors.length === 0) { + this.notificationService.showSuccess('Logbook loaded successfully').subscribe(); + } else { + const errorMessage = `Logbook loaded with ${result.errors.length} errors`; + this.notificationService.showWarning(errorMessage).subscribe(); + } + }, + error: (error) => { + this.isLoading = false; + const errorMessage = this.notificationService.handleError(error, 'Failed to load logbook'); + this.notificationService.showError(errorMessage).subscribe(); + } + }); + } + + toggleEntry(path: string): void { + if (this.expandedEntries.has(path)) { + this.expandedEntries.delete(path); + } else { + this.expandedEntries.add(path); + } + } + + isExpanded(path: string): boolean { + return this.expandedEntries.has(path); + } + + toggleOperations(path: string, event: Event): void { + event.stopPropagation(); + if (this.expandedOperations.has(path)) { + this.expandedOperations.delete(path); + } else { + this.expandedOperations.add(path); + } + } + + areOperationsExpanded(path: string): boolean { + return this.expandedOperations.has(path); + } + + getEntryPath(parent: string, description: string): string { + return parent ? `${parent}/${description}` : description; + } + + hasChildren(entry: LogbookEntryResponse): boolean { + return entry.children && entry.children.length > 0; + } + + viewOperations(): void { + this.router.navigate(['/budget', this.budgetId]); + } + + setDateRange(type: 'currentMonth' | 'lastMonth' | 'currentYear' | 'lastYear'): void { + const now = new Date(); + let from: Date; + let till: Date; + + switch (type) { + case 'currentMonth': + from = new Date(now.getFullYear(), now.getMonth(), 1); + till = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); + break; + case 'lastMonth': + from = new Date(now.getFullYear(), now.getMonth() - 1, 1); + till = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); + break; + case 'currentYear': + from = new Date(now.getFullYear(), 0, 1); + till = new Date(now.getFullYear(), 11, 31, 23, 59, 59); + break; + case 'lastYear': + from = new Date(now.getFullYear() - 1, 0, 1); + till = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59); + break; + } + + this.fromDate = from.toISOString().slice(0, 16); + this.tillDate = till.toISOString().slice(0, 16); + this.loadLogbook(); + } + + hasMetadata(error: any): boolean { + return error.metadata && Object.keys(error.metadata).length > 0; + } + + getMetadataKeys(metadata: Record): string[] { + return Object.keys(metadata); + } + + formatMetadataValue(value: any): string { + if (value === null || value === undefined) { + return 'null'; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + return String(value); + } + + hasNestedReasons(error: any): boolean { + return error.reasons && error.reasons.length > 0; + } + + hasOperations(entry: LogbookEntryResponse): boolean { + return entry.operations && entry.operations.length > 0; + } + + onDeleteOperation(operation: OperationResponse): void { + const confirmMessage = `Are you sure you want to delete this operation?\n\n${operation.description}\n${operation.amount.value} ${operation.amount.currencyCode}\n\nThis action cannot be undone.`; + + if (!confirm(confirmMessage)) { + return; + } + + this.isLoading = true; + + this.operationsHelper.deleteOperation(this.budgetId, operation.id).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map((e: any) => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to delete operation: ${errorMessage}`).subscribe(); + } else { + this.notificationService.showSuccess('Operation deleted successfully').subscribe(); + this.loadLogbook(); + } + }, + error: (error) => { + this.isLoading = false; + const errorMessage = this.notificationService.handleError(error, 'Failed to delete operation'); + this.notificationService.showError(errorMessage).subscribe(); + } + }); + } + + onUpdateOperation(operation: OperationResponse): void { + this.isLoading = true; + + this.operationsHelper.updateOperation(this.budgetId, operation).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to update operation: ${errorMessage}`).subscribe(); + } else { + this.notificationService.showSuccess('Operation updated successfully').subscribe(); + this.loadLogbook(); + } + }, + error: (error) => { + this.isLoading = false; + const errorMessage = this.notificationService.handleError(error, 'Failed to update operation'); + this.notificationService.showError(errorMessage).subscribe(); + } + }); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts index 8769bd49..2b2cddd0 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts @@ -9,7 +9,8 @@ import { ImportResultResponse, UpdateResultResponse, DeleteResultResponse, - RetagResultResponse + RetagResultResponse, + LogbookResponse } from '../budget/models'; import { environment } from '../../environments/environment'; @@ -144,5 +145,30 @@ export class OperationsApiService { { params, withCredentials: true } ); } + + /** + * Get logbook (aggregated operations statistics) + */ + getLogbook( + budgetId: string, + from?: Date, + till?: Date, + criteria?: string + ): Observable { + const params: any = {}; + if (from) { + params.from = from.toISOString(); + } + if (till) { + params.till = till.toISOString(); + } + if (criteria) { + params.criteria = criteria; + } + return this.http.get( + `${this.baseUrl}/budget/${budgetId}/operations/logbook`, + { params, withCredentials: true } + ); + } } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html index 1a8004cd..f9bafe22 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-list/operations-list.component.html @@ -11,6 +11,14 @@

Operations

(click)="navigateToImport()"> Import Operations + +
+ + @if (operations.length > 0) { +
+
+ {{ operations.length }} operations +
+ + + +
+ } @else { +
+

No operations found for this group.

+ +
+ } +
+ +
+ diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.less new file mode 100644 index 00000000..00e3351c --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.less @@ -0,0 +1,48 @@ +@import '../shared/styles/mixins.less'; + +.logbook-group-container { + .page-container(); +} + +.logbook-group-card { + .card-base(); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; + + h2 { + margin: 0; + flex: 1; + } +} + +.operations-section { + margin-top: 1rem; +} + +.operations-count { + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--tui-base-02); + border-radius: 0.5rem; + text-align: center; + font-weight: 600; +} + +.empty-state { + text-align: center; + padding: 3rem 1rem; + + p { + margin-bottom: 1.5rem; + color: var(--tui-text-02); + font-size: 1.125rem; + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.ts new file mode 100644 index 00000000..407ff4d4 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-group/logbook-group.component.ts @@ -0,0 +1,186 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { OperationsApiService } from '../operations-api.service'; +import { + TuiButton, + TuiLoader, + TuiTitle +} from '@taiga-ui/core'; +import { TuiCardLarge } from '@taiga-ui/layout'; +import { NotificationService } from '../shared/notification.service'; +import { OperationsHelperService } from '../shared/operations-helper.service'; +import { OperationsTableComponent } from '../operations-table/operations-table.component'; +import { LogbookResponse, OperationResponse } from '../../budget/models'; + +@Component({ + selector: 'app-logbook-group', + standalone: true, + imports: [ + CommonModule, + TuiButton, + TuiLoader, + TuiCardLarge, + TuiTitle, + OperationsTableComponent + ], + templateUrl: './logbook-group.component.html', + styleUrls: ['./logbook-group.component.less'] +}) +export class LogbookGroupComponent implements OnInit { + budgetId!: string; + rangeName!: string; + criteriaPath!: string; + fromDate!: string; + tillDate!: string; + criteria?: string; + cronExpression?: string; + + isLoading = false; + operations: OperationResponse[] = []; + groupTitle = ''; + + constructor( + private route: ActivatedRoute, + private router: Router, + private operationsApi: OperationsApiService, + private notificationService: NotificationService, + private operationsHelper: OperationsHelperService + ) {} + + ngOnInit(): void { + this.budgetId = this.route.snapshot.params['budgetId']; + this.rangeName = this.route.snapshot.queryParams['rangeName'] || ''; + this.criteriaPath = this.route.snapshot.queryParams['criteriaPath'] || ''; + this.fromDate = this.route.snapshot.queryParams['from'] || ''; + this.tillDate = this.route.snapshot.queryParams['till'] || ''; + this.criteria = this.route.snapshot.queryParams['criteria']; + this.cronExpression = this.route.snapshot.queryParams['cronExpression']; + + const pathParts = this.criteriaPath.split('/'); + const criteriaName = pathParts[pathParts.length - 1] || 'Group'; + this.groupTitle = `${criteriaName} - ${this.rangeName}`; + + this.loadOperations(); + } + + loadOperations(): void { + this.isLoading = true; + + const from = this.fromDate ? new Date(this.fromDate) : undefined; + const till = this.tillDate ? new Date(this.tillDate) : undefined; + + this.operationsApi.getLogbook( + this.budgetId, + from, + till, + this.criteria, + this.cronExpression + ).subscribe({ + next: (result: LogbookResponse) => { + this.isLoading = false; + + // Find the specific range and criteria path + const rangedEntry = result.ranges.find(r => r.range.name === this.rangeName); + if (rangedEntry) { + const entry = this.findEntryByPath(rangedEntry.entry, this.criteriaPath); + if (entry) { + this.operations = entry.operations || []; + } + } + + if (this.operations.length === 0) { + this.notificationService.showWarning('No operations found for this group').subscribe(); + } + }, + error: (error) => { + this.isLoading = false; + const errorMessage = this.notificationService.handleError(error, 'Failed to load operations'); + this.notificationService.showError(errorMessage).subscribe(); + } + }); + } + + private findEntryByPath(entry: any, targetPath: string): any { + const currentPath = entry.description; + + if (currentPath === targetPath) { + return entry; + } + + if (targetPath.startsWith(currentPath + '/')) { + const remainingPath = targetPath.substring(currentPath.length + 1); + + for (const child of (entry.children || [])) { + const found = this.findEntryByPath(child, remainingPath); + if (found) return found; + } + } + + return null; + } + + backToLogbook(): void { + this.router.navigate(['/budget', this.budgetId, 'operations', 'logbook'], { + queryParams: { + from: this.fromDate, + till: this.tillDate, + criteria: this.criteria, + cronExpression: this.cronExpression + } + }); + } + + onDeleteOperation(operation: OperationResponse): void { + const confirmMessage = `Are you sure you want to delete this operation?\n\n${operation.description}\n${operation.amount.value} ${operation.amount.currencyCode}\n\nThis action cannot be undone.`; + + if (!confirm(confirmMessage)) { + return; + } + + this.isLoading = true; + + this.operationsHelper.deleteOperation(this.budgetId, operation.id).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map((e: any) => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to delete operation: ${errorMessage}`).subscribe(); + } else { + this.notificationService.showSuccess('Operation deleted successfully').subscribe(); + this.loadOperations(); + } + }, + error: (error) => { + this.isLoading = false; + const errorMessage = this.notificationService.handleError(error, 'Failed to delete operation'); + this.notificationService.showError(errorMessage).subscribe(); + } + }); + } + + onUpdateOperation(operation: OperationResponse): void { + this.isLoading = true; + + this.operationsHelper.updateOperation(this.budgetId, operation).subscribe({ + next: (result) => { + this.isLoading = false; + + if (result.errors && result.errors.length > 0) { + const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); + this.notificationService.showError(`Failed to update operation: ${errorMessage}`).subscribe(); + } else { + this.notificationService.showSuccess('Operation updated successfully').subscribe(); + this.loadOperations(); + } + }, + error: (error) => { + this.isLoading = false; + const errorMessage = this.notificationService.handleError(error, 'Failed to update operation'); + this.notificationService.showError(errorMessage).subscribe(); + } + }); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.html index 3563d065..df763f16 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.html @@ -13,64 +13,85 @@

Filters

(criteriaSubmitted)="onCriteriaSubmitted($event)" (criteriaCleared)="onCriteriaCleared()"> -
-
-
- - - - +
+
+
+
+ + + + +
+ +
+ + + + +
-
+
+ + + + +
+
+ +
+
- +
-
- -
- - - - + + +
@@ -91,9 +112,59 @@

Filters

Logbook Statistics

- @if (logbook.root) { -
- + @if (criteriaRows.length > 0 && ranges.length > 0) { +
+ + + + + @for (range of ranges; track range.name) { + + } + + + + @for (row of criteriaRows; track row.path) { + @if (isRowVisible(row)) { + + + @for (range of ranges; track range.name) { + + } + } + } + +
Category + {{ range.name }} +
+
+ @if (row.hasChildren) { + + {{ isRowExpanded(row.path) ? '▼' : '▶' }} + + } @else { + + } + {{ row.description }} +
+
+ @if (getEntryForRange(row, range.name); as entry) { + @if (entry.sum.value !== 0) { +
+ {{ entry.sum.value | currencyFormat: entry.sum.currencyCode }} +
+ } @else { +
-
+ } + } @else { +
-
+ } +
} @else {
@@ -158,63 +229,3 @@

Errors ({{ logbook.errors.length }})

- -
-
- - -
- - Sum: - - {{ entry.sum.value | currencyFormat: entry.sum.currencyCode }} - - - - Count: - {{ entry.operationsCount }} - - - Period: - {{ entry.from | dateFormat }} - {{ entry.till | dateFormat }} - -
-
- - @if (isExpanded(getEntryPath(path, entry.description))) { -
- @if (hasOperations(entry) && !hasChildren(entry)) { -
-
- {{ areOperationsExpanded(getEntryPath(path, entry.description)) ? '▼' : '▶' }} -

Operations ({{ entry.operations.length }})

-
- - @if (areOperationsExpanded(getEntryPath(path, entry.description))) { -
- - -
- } -
- } - - @if (hasChildren(entry)) { -
- @for (child of entry.children; track $index) { - - } -
- } -
- } -
-
- diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.less b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.less index 5b387319..a9b476d3 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.less +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.less @@ -16,8 +16,14 @@ } } -.date-range-section { +.additional-parameters { margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.date-range-section { padding: 1rem; background: var(--tui-base-02); border-radius: 0.5rem; @@ -41,6 +47,16 @@ flex-wrap: wrap; } +.cron-section { + padding: 1rem; + background: var(--tui-base-02); + border-radius: 0.5rem; +} + +.cron-field { + margin-bottom: 0.5rem; +} + .action-buttons { display: flex; gap: 1rem; @@ -56,44 +72,94 @@ } } -.logbook-tree { - background: var(--tui-base-01); +.logbook-table-container { + overflow-x: auto; border: 1px solid var(--tui-base-04); border-radius: 0.5rem; - padding: 1rem; + background: var(--tui-base-01); } -.logbook-entry { - margin: 0.5rem 0; +.logbook-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; - .entry-header { - cursor: pointer; + thead { + background: var(--tui-primary-hover); + position: sticky; + top: 0; + z-index: 10; - &:hover { - background: var(--tui-base-03); + th { + padding: 1rem; + text-align: left; + font-weight: 700; + color: var(--tui-text-01); + border-bottom: 2px solid var(--tui-primary); + border-right: 1px solid var(--tui-base-04); + white-space: nowrap; + + &:last-child { + border-right: none; + } + + &.criteria-column { + min-width: 250px; + position: sticky; + left: 0; + background: var(--tui-primary-hover); + z-index: 11; + } + + &.range-column { + min-width: 200px; + } + } + } + + tbody { + tr.criteria-row { + border-bottom: 1px solid var(--tui-base-04); + transition: background-color 0.15s; + + &:hover { + background: var(--tui-base-02); + } + + &[data-level="0"] { + background: var(--tui-base-02); + font-weight: 600; + } + + &[data-level="1"] { + background: var(--tui-base-01); + } + + td { + padding: 0.75rem 1rem; + vertical-align: middle; + border-right: 1px solid var(--tui-base-04); + + &:last-child { + border-right: none; + } + } } } } -.entry-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.75rem 1rem; - background: var(--tui-base-02); - border-radius: 0.25rem; - transition: background-color 0.15s; - gap: 1rem; - flex-wrap: wrap; +.criteria-cell { + position: sticky; + left: 0; + background: inherit; + z-index: 5; + min-width: 250px; } -.entry-info { +.criteria-info { display: flex; align-items: center; gap: 0.5rem; - font-weight: 600; - color: var(--tui-text-01); - min-width: 200px; } .expand-icon { @@ -101,34 +167,40 @@ text-align: center; color: var(--tui-text-02); font-size: 0.75rem; + cursor: pointer; + + &.placeholder { + visibility: hidden; + } + + &:hover { + color: var(--tui-text-01); + } } -.entry-description { +.criteria-name { font-size: 1rem; } -.entry-stats { - display: flex; - gap: 1.5rem; - flex-wrap: wrap; -} - -.stat-item { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; -} - -.stat-label { - color: var(--tui-text-02); - font-weight: 500; +.data-cell { + min-width: 150px; + text-align: right; + + &.clickable { + cursor: pointer; + transition: all 0.15s; + + &:hover { + background: var(--tui-base-03); + transform: scale(1.02); + } + } } -.stat-value { - color: var(--tui-text-01); +.sum-value { font-family: monospace; - font-weight: 600; + font-weight: 700; + font-size: 1rem; &.positive { color: var(--tui-success-fill); @@ -139,54 +211,10 @@ } } -.entry-content { - margin-top: 0.5rem; -} - -.entry-operations-section { - margin-bottom: 1rem; -} - -.operations-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1rem; - background: var(--tui-base-02); - border-radius: 0.25rem; - cursor: pointer; - transition: background-color 0.15s; - - &:hover { - background: var(--tui-base-03); - } -} - -.operations-toggle-icon { - width: 1rem; +.empty-cell { text-align: center; - color: var(--tui-text-02); - font-size: 0.75rem; -} - -.operations-title { - margin: 0; - font-size: 0.9375rem; - font-weight: 600; - color: var(--tui-text-02); -} - -.entry-operations { - padding: 1rem; - background: var(--tui-base-01); - border-radius: 0.25rem; - margin-top: 0.5rem; -} - -.entry-children { - margin-top: 1rem; - border-left: 2px solid var(--tui-base-04); - padding-left: 0.5rem; + color: var(--tui-text-03); + font-style: italic; } .empty-state { diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts index 9251c356..33863314 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts @@ -13,11 +13,18 @@ import { import { TuiCardLarge } from '@taiga-ui/layout'; import { TuiAccordion } from '@taiga-ui/kit'; import { NotificationService } from '../shared/notification.service'; -import { OperationsHelperService } from '../shared/operations-helper.service'; import { CriteriaFilterComponent } from '../shared/components/criteria-filter/criteria-filter.component'; -import { OperationsTableComponent } from '../operations-table/operations-table.component'; +import { ExamplesSectionComponent } from '../shared/components/examples-section/examples-section.component'; import { CriteriaExample } from '../shared/models/example.interface'; -import { LogbookEntryResponse, LogbookResponse, OperationResponse } from '../../budget/models'; +import { LogbookEntryResponse, LogbookResponse, RangedLogbookEntryResponse, NamedRangeResponse } from '../../budget/models'; + +interface CriteriaRow { + description: string; + path: string; + level: number; + rangeData: Map; + hasChildren: boolean; +} import { DateFormatPipe } from '../shared/pipes/date-format.pipe'; import { CurrencyFormatPipe } from '../shared/pipes/currency-format.pipe'; @@ -35,7 +42,7 @@ import { CurrencyFormatPipe } from '../shared/pipes/currency-format.pipe'; TuiLabel, TuiAccordion, CriteriaFilterComponent, - OperationsTableComponent, + ExamplesSectionComponent, DateFormatPipe, CurrencyFormatPipe ], @@ -50,9 +57,11 @@ export class LogbookViewComponent implements OnInit { currentCriteria = ''; fromDate: string = ''; tillDate: string = ''; + cronExpression = ''; - expandedEntries = new Set(); - expandedOperations = new Set(); + ranges: NamedRangeResponse[] = []; + criteriaRows: CriteriaRow[] = []; + expandedRows = new Set(); criteriaExamples: CriteriaExample[] = [ { label: 'All operations:', code: 'o => true' }, @@ -63,29 +72,73 @@ export class LogbookViewComponent implements OnInit { { label: 'Amount range:', code: 'o => o.Amount.Amount >= -1000 && o.Amount.Amount <= -100' } ]; + cronExamples: CriteriaExample[] = [ + { label: 'Daily:', code: '0 0 * * *' }, + { label: 'Weekly (Mondays):', code: '0 0 * * 1' }, + { label: 'Monthly (1st day):', code: '0 0 1 * *' }, + { label: 'Bi-weekly:', code: '0 0 1,15 * *' }, + { label: 'Quarterly:', code: '0 0 1 1,4,7,10 *' } + ]; + constructor( private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private notificationService: NotificationService, - private operationsHelper: OperationsHelperService + private notificationService: NotificationService ) { this.resetToDefaultDateRange(); } ngOnInit(): void { this.budgetId = this.route.snapshot.params['budgetId']; + + // Restore filter parameters from query params if present + const queryParams = this.route.snapshot.queryParams; + + if (queryParams['from']) { + this.fromDate = queryParams['from']; + } + + if (queryParams['till']) { + this.tillDate = queryParams['till']; + } + + if (queryParams['criteria']) { + this.currentCriteria = queryParams['criteria']; + } + + if (queryParams['cronExpression']) { + this.cronExpression = queryParams['cronExpression']; + } + this.loadLogbook(); } onCriteriaSubmitted(criteria: string): void { this.currentCriteria = criteria; - this.loadLogbook(); + this.updateUrlAndLoadLogbook(); } onCriteriaCleared(): void { this.currentCriteria = ''; + this.cronExpression = ''; this.resetToDefaultDateRange(); + this.updateUrlAndLoadLogbook(); + } + + private updateUrlAndLoadLogbook(): void { + // Update URL with current filter state + this.router.navigate([], { + relativeTo: this.route, + queryParams: { + from: this.fromDate || undefined, + till: this.tillDate || undefined, + criteria: this.currentCriteria || undefined, + cronExpression: this.cronExpression || undefined + }, + queryParamsHandling: 'merge' + }); + this.loadLogbook(); } @@ -101,8 +154,9 @@ export class LogbookViewComponent implements OnInit { loadLogbook(): void { this.isLoading = true; this.logbook = null; - this.expandedEntries.clear(); - this.expandedOperations.clear(); + this.ranges = []; + this.criteriaRows = []; + this.expandedRows.clear(); const from = this.fromDate ? new Date(this.fromDate) : undefined; const till = this.tillDate ? new Date(this.tillDate) : undefined; @@ -111,15 +165,19 @@ export class LogbookViewComponent implements OnInit { this.budgetId, from, till, - this.currentCriteria || undefined + this.currentCriteria || undefined, + this.cronExpression || undefined ).subscribe({ next: (result) => { this.isLoading = false; this.logbook = result; - if (result.errors.length === 0) { - this.notificationService.showSuccess('Logbook loaded successfully').subscribe(); - } else { + if (result.ranges && result.ranges.length > 0) { + this.ranges = result.ranges.map(r => r.range); + this.criteriaRows = this.buildCriteriaRows(result.ranges); + } + + if (result.errors.length > 0) { const errorMessage = `Logbook loaded with ${result.errors.length} errors`; this.notificationService.showWarning(errorMessage).subscribe(); } @@ -132,37 +190,122 @@ export class LogbookViewComponent implements OnInit { }); } - toggleEntry(path: string): void { - if (this.expandedEntries.has(path)) { - this.expandedEntries.delete(path); - } else { - this.expandedEntries.add(path); + private buildCriteriaRows(rangedEntries: RangedLogbookEntryResponse[]): CriteriaRow[] { + const rows: CriteriaRow[] = []; + + // Get all unique criteria paths from the first range to establish row structure + if (rangedEntries.length === 0) return rows; + + const firstEntry = rangedEntries[0].entry; + this.collectCriteriaPaths(firstEntry, '', 0, rows, rangedEntries); + + return rows; + } + + private collectCriteriaPaths( + entry: LogbookEntryResponse, + parentPath: string, + level: number, + rows: CriteriaRow[], + rangedEntries: RangedLogbookEntryResponse[] + ): void { + const currentPath = parentPath ? `${parentPath}/${entry.description}` : entry.description; + + // Create range data map for this criteria + const rangeData = new Map(); + + for (const rangedEntry of rangedEntries) { + const entryData = this.findEntryByPath(rangedEntry.entry, currentPath); + if (entryData) { + rangeData.set(rangedEntry.range.name, entryData); + } + } + + const hasChildren = entry.children && entry.children.length > 0; + + rows.push({ + description: entry.description, + path: currentPath, + level, + rangeData, + hasChildren + }); + + // Recursively add children + if (hasChildren) { + for (const child of entry.children) { + this.collectCriteriaPaths(child, currentPath, level + 1, rows, rangedEntries); + } } } - isExpanded(path: string): boolean { - return this.expandedEntries.has(path); + private findEntryByPath(entry: LogbookEntryResponse, targetPath: string): LogbookEntryResponse | null { + const currentPath = entry.description; + + if (currentPath === targetPath) { + return entry; + } + + if (targetPath.startsWith(currentPath + '/')) { + const remainingPath = targetPath.substring(currentPath.length + 1); + + for (const child of entry.children) { + const found = this.findEntryByPath(child, remainingPath); + if (found) return found; + } + } + + return null; } - toggleOperations(path: string, event: Event): void { - event.stopPropagation(); - if (this.expandedOperations.has(path)) { - this.expandedOperations.delete(path); + toggleRow(path: string): void { + if (this.expandedRows.has(path)) { + this.expandedRows.delete(path); } else { - this.expandedOperations.add(path); + this.expandedRows.add(path); } } - areOperationsExpanded(path: string): boolean { - return this.expandedOperations.has(path); + isRowExpanded(path: string): boolean { + return this.expandedRows.has(path); } - getEntryPath(parent: string, description: string): string { - return parent ? `${parent}/${description}` : description; + isRowVisible(row: CriteriaRow): boolean { + if (row.level === 0) return true; + + // Check if all parent rows are expanded + const pathParts = row.path.split('/'); + for (let i = 1; i < pathParts.length; i++) { + const parentPath = pathParts.slice(0, i).join('/'); + if (!this.expandedRows.has(parentPath)) { + return false; + } + } + return true; } - hasChildren(entry: LogbookEntryResponse): boolean { - return entry.children && entry.children.length > 0; + viewGroupOperations(row: CriteriaRow, rangeName: string, event: Event): void { + event.stopPropagation(); + + this.router.navigate(['/budget', this.budgetId, 'operations', 'logbook', 'group'], { + queryParams: { + rangeName: rangeName, + criteriaPath: row.path, + from: this.fromDate, + till: this.tillDate, + criteria: this.currentCriteria || undefined, + cronExpression: this.cronExpression || undefined + } + }); + } + + getEntryForRange(row: CriteriaRow, rangeName: string): LogbookEntryResponse | undefined { + return row.rangeData.get(rangeName); + } + + hasOperationsInRange(row: CriteriaRow, rangeName: string): boolean { + const entry = this.getEntryForRange(row, rangeName); + return entry ? (entry.operations && entry.operations.length > 0) : false; } viewOperations(): void { @@ -195,7 +338,7 @@ export class LogbookViewComponent implements OnInit { this.fromDate = from.toISOString().slice(0, 16); this.tillDate = till.toISOString().slice(0, 16); - this.loadLogbook(); + this.updateUrlAndLoadLogbook(); } hasMetadata(error: any): boolean { @@ -223,61 +366,7 @@ export class LogbookViewComponent implements OnInit { hasNestedReasons(error: any): boolean { return error.reasons && error.reasons.length > 0; } +} - hasOperations(entry: LogbookEntryResponse): boolean { - return entry.operations && entry.operations.length > 0; - } - - onDeleteOperation(operation: OperationResponse): void { - const confirmMessage = `Are you sure you want to delete this operation?\n\n${operation.description}\n${operation.amount.value} ${operation.amount.currencyCode}\n\nThis action cannot be undone.`; - - if (!confirm(confirmMessage)) { - return; - } - - this.isLoading = true; - - this.operationsHelper.deleteOperation(this.budgetId, operation.id).subscribe({ - next: (result) => { - this.isLoading = false; - - if (result.errors && result.errors.length > 0) { - const errorMessage = result.errors.map((e: any) => e.message || 'Unknown error').join('; '); - this.notificationService.showError(`Failed to delete operation: ${errorMessage}`).subscribe(); - } else { - this.notificationService.showSuccess('Operation deleted successfully').subscribe(); - this.loadLogbook(); - } - }, - error: (error) => { - this.isLoading = false; - const errorMessage = this.notificationService.handleError(error, 'Failed to delete operation'); - this.notificationService.showError(errorMessage).subscribe(); - } - }); - } - onUpdateOperation(operation: OperationResponse): void { - this.isLoading = true; - - this.operationsHelper.updateOperation(this.budgetId, operation).subscribe({ - next: (result) => { - this.isLoading = false; - - if (result.errors && result.errors.length > 0) { - const errorMessage = result.errors.map(e => e.message || 'Unknown error').join('; '); - this.notificationService.showError(`Failed to update operation: ${errorMessage}`).subscribe(); - } else { - this.notificationService.showSuccess('Operation updated successfully').subscribe(); - this.loadLogbook(); - } - }, - error: (error) => { - this.isLoading = false; - const errorMessage = this.notificationService.handleError(error, 'Failed to update operation'); - this.notificationService.showError(errorMessage).subscribe(); - } - }); - } -} diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts index 2b2cddd0..d1bfc0d3 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/operations-api.service.ts @@ -153,7 +153,8 @@ export class OperationsApiService { budgetId: string, from?: Date, till?: Date, - criteria?: string + criteria?: string, + cronExpression?: string ): Observable { const params: any = {}; if (from) { @@ -165,6 +166,9 @@ export class OperationsApiService { if (criteria) { params.criteria = criteria; } + if (cronExpression) { + params.cronExpression = cronExpression; + } return this.http.get( `${this.baseUrl}/budget/${budgetId}/operations/logbook`, { params, withCredentials: true } diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.ts index a790e309..1d5317bf 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/shared/components/criteria-filter/criteria-filter.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, OnChanges, SimpleChanges, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { TuiButton, TuiTextfield, TuiLabel } from '@taiga-ui/core'; @@ -23,7 +23,7 @@ import { CtrlEnterDirective } from '../../directives/ctrl-enter.directive'; templateUrl: './criteria-filter.component.html', styleUrls: ['./criteria-filter.component.less'] }) -export class CriteriaFilterComponent implements OnInit { +export class CriteriaFilterComponent implements OnInit, OnChanges { @Input() initialCriteria = 'o => true'; @Input() examples: CriteriaExample[] = []; @Input() showExamplesInitially = false; @@ -40,6 +40,12 @@ export class CriteriaFilterComponent implements OnInit { }); } + ngOnChanges(changes: SimpleChanges): void { + if (changes['initialCriteria'] && this.filterForm && !changes['initialCriteria'].firstChange) { + this.filterForm.patchValue({ criteria: this.initialCriteria }); + } + } + apply(): void { const criteria = this.filterForm.value.criteria; this.criteriaSubmitted.emit(criteria); From dc1b6a8117a22907e91c6c652d3e931038ea6432 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Thu, 23 Oct 2025 20:18:48 +0300 Subject: [PATCH 128/156] feat: Add logbook tree state preservation - Create LogbookStateService to preserve expansion state and scroll position - Save state when navigating to logbook group operations page - Restore expansion state and scroll position when returning to logbook - Clear saved state when filters change (criteria, dates, cron) - State stored per budget for independent tracking - Improves UX by maintaining user's exploration context --- .../logbook-view/logbook-state.service.ts | 33 +++++++++++++++++ .../logbook-view/logbook-view.component.ts | 35 ++++++++++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-state.service.ts diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-state.service.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-state.service.ts new file mode 100644 index 00000000..ff49c154 --- /dev/null +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-state.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; + +interface LogbookState { + expandedRows: Set; + scrollPosition: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class LogbookStateService { + private stateMap = new Map(); + + saveState(budgetId: string, expandedRows: Set, scrollPosition: number): void { + this.stateMap.set(budgetId, { + expandedRows: new Set(expandedRows), + scrollPosition + }); + } + + getState(budgetId: string): LogbookState | undefined { + return this.stateMap.get(budgetId); + } + + clearState(budgetId: string): void { + this.stateMap.delete(budgetId); + } + + hasState(budgetId: string): boolean { + return this.stateMap.has(budgetId); + } +} + diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts index 33863314..85329be2 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.ts @@ -17,6 +17,7 @@ import { CriteriaFilterComponent } from '../shared/components/criteria-filter/cr import { ExamplesSectionComponent } from '../shared/components/examples-section/examples-section.component'; import { CriteriaExample } from '../shared/models/example.interface'; import { LogbookEntryResponse, LogbookResponse, RangedLogbookEntryResponse, NamedRangeResponse } from '../../budget/models'; +import { LogbookStateService } from './logbook-state.service'; interface CriteriaRow { description: string; @@ -84,7 +85,8 @@ export class LogbookViewComponent implements OnInit { private route: ActivatedRoute, private router: Router, private operationsApi: OperationsApiService, - private notificationService: NotificationService + private notificationService: NotificationService, + private logbookStateService: LogbookStateService ) { this.resetToDefaultDateRange(); } @@ -116,13 +118,19 @@ export class LogbookViewComponent implements OnInit { onCriteriaSubmitted(criteria: string): void { this.currentCriteria = criteria; - this.updateUrlAndLoadLogbook(); + this.clearStateAndReload(); } onCriteriaCleared(): void { this.currentCriteria = ''; this.cronExpression = ''; this.resetToDefaultDateRange(); + this.clearStateAndReload(); + } + + private clearStateAndReload(): void { + // Clear saved state when filters change + this.logbookStateService.clearState(this.budgetId); this.updateUrlAndLoadLogbook(); } @@ -156,7 +164,6 @@ export class LogbookViewComponent implements OnInit { this.logbook = null; this.ranges = []; this.criteriaRows = []; - this.expandedRows.clear(); const from = this.fromDate ? new Date(this.fromDate) : undefined; const till = this.tillDate ? new Date(this.tillDate) : undefined; @@ -175,6 +182,19 @@ export class LogbookViewComponent implements OnInit { if (result.ranges && result.ranges.length > 0) { this.ranges = result.ranges.map(r => r.range); this.criteriaRows = this.buildCriteriaRows(result.ranges); + + // Restore expansion state if it exists, otherwise start fresh + const savedState = this.logbookStateService.getState(this.budgetId); + if (savedState) { + this.expandedRows = savedState.expandedRows; + + // Restore scroll position after view is rendered + setTimeout(() => { + window.scrollTo(0, savedState.scrollPosition); + }, 100); + } else { + this.expandedRows.clear(); + } } if (result.errors.length > 0) { @@ -287,6 +307,13 @@ export class LogbookViewComponent implements OnInit { viewGroupOperations(row: CriteriaRow, rangeName: string, event: Event): void { event.stopPropagation(); + // Save current state before navigating + this.logbookStateService.saveState( + this.budgetId, + this.expandedRows, + window.scrollY + ); + this.router.navigate(['/budget', this.budgetId, 'operations', 'logbook', 'group'], { queryParams: { rangeName: rangeName, @@ -338,7 +365,7 @@ export class LogbookViewComponent implements OnInit { this.fromDate = from.toISOString().slice(0, 16); this.tillDate = till.toISOString().slice(0, 16); - this.updateUrlAndLoadLogbook(); + this.clearStateAndReload(); } hasMetadata(error: any): boolean { From 3c1768aa859ced2bc62106ce883b284ab18bca89 Mon Sep 17 00:00:00 2001 From: Nikita V Senkov Date: Thu, 23 Oct 2025 20:43:39 +0300 Subject: [PATCH 129/156] feat: optional currency --- .../logbook-view/logbook-view.component.html | 56 +++++++++---------- .../shared/pipes/currency-format.pipe.ts | 4 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.html b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.html index df763f16..273474fa 100644 --- a/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.html +++ b/src/Hosts/NVs.Budget.Hosts.Web.Client/budget-client/src/app/operations/logbook-view/logbook-view.component.html @@ -2,45 +2,45 @@

Operations Logbook

- +

Filters

- + - +
-
- +
-
- +
- - - -
- +
-
- -
@@ -98,7 +98,7 @@

Filters

-