diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3a01ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# ── .NET ────────────────────────────────────────────────────────────────────── +bin/ +obj/ +*.user +.vs/ +TestResults/ +*.suo +*.ncrunch* + +# Local secrets — never commit these +**/appsettings.Development.json +**/appsettings.Production.json +**/appsettings.Local.json + +# ── Node / React ─────────────────────────────────────────────────────────────── +node_modules/ +dist/ +.env +.env.local +.env.*.local + +# ── IDE ──────────────────────────────────────────────────────────────────────── +.idea/ +.vscode/ + +# ── Misc ─────────────────────────────────────────────────────────────────────── +.claude/ +*.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f9cbb60 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Structure + +Mono-repo with two projects: + +- `PageManager.Api/` — .NET 10 Web API (solution root contains `PageManager.Api.slnx`) + - `PageManager.Api/PageManager.Api/` — the actual C# project + - `PageManager.Api/PageManager.Api.Tests/` — xunit test project +- `PageManager.Web/` — React 19 + Vite + TypeScript SPA +- `compose.yaml` — root-level Docker Compose orchestrating both services + +## Development Commands + +### API (`PageManager.Api/`) + +```bash +cd PageManager.Api +dotnet run --project PageManager.Api # runs on http://localhost:5278 +dotnet build +dotnet test # all tests +dotnet test --filter "FullyQualifiedName~Unit" # unit only (no Docker needed) +dotnet test --filter "FullyQualifiedName~Integration" # integration only (Docker required) +``` + +### Migrations (`PageManager.Api/`) + +EF Core tooling targets the `PageManager.Api` project. All commands run from `PageManager.Api/`. + +```bash +# Create a migration after changing Data/Models +dotnet ef migrations add --project PageManager.Api + +# Apply pending migrations to the database +dotnet ef database update --project PageManager.Api + +# Roll back to a specific migration +dotnet ef database update --project PageManager.Api + +# Generate SQL script for a migration (useful for production deploys) +dotnet ef migrations script --project PageManager.Api +``` + +Install the EF global tool if not present: `dotnet tool install --global dotnet-ef` + +### Web (`PageManager.Web/`) + +```bash +cd PageManager.Web +npm install +npm run dev # http://localhost:5173 +npm run build +npm run preview # preview production build locally +npm test # single pass (CI) +npm run test:watch # interactive watch (development) +npm run test:coverage +``` + +### Docker (both services) + +```bash +docker compose up --build # web → :8080, api → :5278 +``` + +## Architecture + +### API → Web connection + +**Dev:** Vite dev server proxies `/api/*` to `http://localhost:5278` (configured in `PageManager.Web/vite.config.ts`). The API enables CORS for `http://localhost:5173` in `Program.cs` — only in the `Development` environment. + +**Production (Docker):** nginx (`PageManager.Web/nginx.conf`) proxies `/api/*` to `http://pagemanager.api:8080` (the compose service name). All other routes fall back to `index.html` for SPA routing. + +## Conventions +- Controllers are thin — logic lives in Services +- EF Core models in Data/Models, DTOs in Api/Dtos +- React components in PascalCase, files match component name +- API calls go through src/api/*.ts typed wrappers, never raw fetch in components +- Design reference: src/Leafreader.Web/design-reference.html + +## Testing + +**Rule: every code change must be followed by running the relevant tests and fixing any failures before the task is considered done.** + +### After API changes +```bash +cd PageManager.Api +dotnet test --filter "FullyQualifiedName~Unit" # always run; no Docker needed +# If integration-relevant (endpoint, EF query, DB schema): also run: +dotnet test --filter "FullyQualifiedName~Integration" # requires Docker +``` + +### After Web changes +```bash +cd PageManager.Web +npm test +``` + +### Test locations + +| Layer | Location | Tool | +|---|---|---| +| API unit | `PageManager.Api.Tests/Unit/Services/` | xunit + NSubstitute + FluentAssertions | +| API integration | `PageManager.Api.Tests/Integration/` | xunit + Testcontainers (postgres:17-alpine) + WebApplicationFactory | +| Web unit | `src/**/__tests__/` | Vitest + Testing Library | + +### What to test and when + +- **New service method or DTO mapping** → add unit test in `Unit/Services/` +- **New or changed endpoint** → add integration test in `Integration/BooksControllerTests.cs` (or a new `*ControllerTests.cs`) +- **New utility function** (`utils.ts`) → add unit test in a sibling `__tests__/` folder +- **New React component with logic** → add component test in `__tests__/` +- **Private logic inside a component** → extract to `utils.ts` first, then test the exported function + +### What does NOT get tested +- `BooksRepository` — pure EF Include chains, covered by integration tests +- `BooksController` — too thin, covered by integration tests +- CSS Modules, `Sidebar` (static nav), `App.tsx` routing +- `importQueue.ts` — currently mock stubs; add tests when real HTTP is introduced + +### Decision rules +- Unit test: any service method, utility function, or DTO mapping with conditional logic or data transformation +- Integration test: any new endpoint, EF Core query change, or PostgreSQL-specific feature +- Don't duplicate: if unit tests own mapping correctness, integration tests own HTTP contract + DB round-trip +- Extract before testing: private logic inside a component must move to `utils.ts` before writing tests + +### Infrastructure notes +- Integration tests use `[Collection("Postgres")]` — one container shared across all integration test classes +- Each integration test class truncates tables in `InitializeAsync` to ensure isolation +- `Book.Formats` and `Book.Genres` are `text[]` (PostgreSQL-specific) — always use real Postgres for integration tests, never InMemory +- `TestWebAppFactory` sets `UseEnvironment("Testing")` — CORS and OpenAPI middleware are skipped diff --git a/PageManager.Api/.dockerignore b/PageManager.Api/.dockerignore new file mode 100644 index 0000000..38bece4 --- /dev/null +++ b/PageManager.Api/.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/PageManager.Api/PageManager.Api.Tests/GlobalUsings.cs b/PageManager.Api/PageManager.Api.Tests/GlobalUsings.cs new file mode 100644 index 0000000..25f194d --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using Xunit; +global using FluentAssertions; +global using NSubstitute; diff --git a/PageManager.Api/PageManager.Api.Tests/Helpers/BookFactory.cs b/PageManager.Api/PageManager.Api.Tests/Helpers/BookFactory.cs new file mode 100644 index 0000000..16d8de2 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Helpers/BookFactory.cs @@ -0,0 +1,70 @@ +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Tests.Helpers; + +public static class BookFactory +{ + public static Book Create( + int id = 1, + string title = "Test Book", + int? year = 2024, + string? publisher = "Test Publisher", + int? pages = 300, + string? description = "A test book.", + string[] formats = null!, + string color = "#6366f1", + string[] genres = null!, + string? coverUrl = null, + string? isbn = null, + int? hardcoverId = null) + { + return new Book + { + Id = id, + Title = title, + Year = year, + Publisher = publisher, + Pages = pages, + Description = description, + Formats = formats ?? ["epub"], + Color = color, + Genres = genres ?? ["Fiction"], + CoverUrl = coverUrl, + Isbn = isbn, + HardcoverId = hardcoverId, + BookAuthors = [], + SeriesEntries = [], + }; + } + + public static Book WithAuthors(this Book book, params (int Id, string Name)[] authors) + { + foreach (var (id, name) in authors) + { + var author = new Author { Id = id, Name = name }; + book.BookAuthors.Add(new BookAuthor + { + BookId = book.Id, + AuthorId = id, + Author = author, + Book = book, + }); + } + return book; + } + + public static Book WithSeries(this Book book, int seriesId = 1, string seriesName = "Test Series", double position = 1.0, string? arc = null) + { + var series = new Series { Id = seriesId, Name = seriesName }; + book.SeriesEntries.Add(new SeriesEntry + { + SeriesId = seriesId, + BookId = book.Id, + Series = series, + Book = book, + Position = position, + Arc = arc, + }); + return book; + } +} diff --git a/PageManager.Api/PageManager.Api.Tests/Integration/BooksControllerTests.cs b/PageManager.Api/PageManager.Api.Tests/Integration/BooksControllerTests.cs new file mode 100644 index 0000000..8e53683 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Integration/BooksControllerTests.cs @@ -0,0 +1,278 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Data; +using PageManager.Api.Data.Models; +using PageManager.Api.Services; +using PageManager.Api.Tests.Integration.Fixtures; + +namespace PageManager.Api.Tests.Integration; + +[Collection("Postgres")] +public class BooksControllerTests : IAsyncLifetime +{ + private readonly TestWebAppFactory _factory; + private readonly HttpClient _client; + + public BooksControllerTests(PostgresFixture postgres) + { + _factory = new TestWebAppFactory(postgres); + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.ExecuteSqlRawAsync( + "TRUNCATE book_authors, series_entries, books, authors, series RESTART IDENTITY CASCADE"); + } + + public Task DisposeAsync() + { + _client.Dispose(); + _factory.Dispose(); + return Task.CompletedTask; + } + + // ── GET /api/books ──────────────────────────────────────────────────────── + + [Fact] + public async Task GetBooks_EmptyDb_Returns200WithEmptyArray() + { + var response = await _client.GetAsync("/api/books"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var books = await response.Content.ReadFromJsonAsync(); + books.Should().BeEmpty(); + } + + [Fact] + public async Task GetBooks_SeededBooks_Returns200WithCorrectCountAndArrayFields() + { + await SeedBookAsync(title: "Dune", formats: ["epub", "mobi"], genres: ["Science Fiction"]); + await SeedBookAsync(title: "Foundation", formats: ["pdf"], genres: ["Science Fiction", "Classic"]); + + var response = await _client.GetAsync("/api/books"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var books = await response.Content.ReadFromJsonAsync(); + books.Should().HaveCount(2); + var dune = books!.Single(b => b.Title == "Dune"); + dune.Formats.Should().BeEquivalentTo(new[] { "epub", "mobi" }); + dune.Genres.Should().BeEquivalentTo(new[] { "Science Fiction" }); + var foundation = books.Single(b => b.Title == "Foundation"); + foundation.Formats.Should().BeEquivalentTo(new[] { "pdf" }); + } + + // ── GET /api/books/{id} ─────────────────────────────────────────────────── + + [Fact] + public async Task GetBook_Exists_Returns200WithDtoAndAuthors() + { + var id = await SeedBookAsync(title: "The Name of the Wind", authorName: "Patrick Rothfuss"); + + var response = await _client.GetAsync($"/api/books/{id}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var book = await response.Content.ReadFromJsonAsync(); + book.Should().NotBeNull(); + book!.Title.Should().Be("The Name of the Wind"); + book.Authors.Should().ContainSingle(a => a.Name == "Patrick Rothfuss"); + } + + [Fact] + public async Task GetBook_NotFound_Returns404() + { + var response = await _client.GetAsync("/api/books/99999"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── PUT /api/books/{id} ─────────────────────────────────────────────────── + + [Fact] + public async Task UpdateBook_Exists_Returns200WithUpdatedFields() + { + var id = await SeedBookAsync(title: "Original Title"); + + var req = new UpdateBookRequest + { + Title = "Updated Title", + Year = 2025, + Publisher = "New Publisher", + Pages = 500, + Formats = ["epub", "mobi"], + Genres = ["Fantasy"], + Color = "#ff0000", + }; + + var response = await _client.PutAsJsonAsync($"/api/books/{id}", req); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var book = await response.Content.ReadFromJsonAsync(); + book!.Title.Should().Be("Updated Title"); + book.Year.Should().Be(2025); + book.Publisher.Should().Be("New Publisher"); + } + + [Fact] + public async Task UpdateBook_NotFound_Returns404() + { + var req = new UpdateBookRequest { Title = "Whatever" }; + + var response = await _client.PutAsJsonAsync("/api/books/99999", req); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task UpdateBook_PersistsArrayFieldsToDb() + { + var id = await SeedBookAsync(title: "Array Test"); + + var req = new UpdateBookRequest + { + Title = "Array Test", + Formats = ["epub", "pdf", "mobi"], + Genres = ["Fantasy", "Adventure"], + Color = "#6366f1", + }; + + await _client.PutAsJsonAsync($"/api/books/{id}", req); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var book = await db.Books.FindAsync(id); + book!.Formats.Should().BeEquivalentTo(["epub", "pdf", "mobi"]); + book.Genres.Should().BeEquivalentTo(["Fantasy", "Adventure"]); + } + + // ── POST /api/books ─────────────────────────────────────────────────────── + + [Fact] + public async Task CreateBook_HardcoverReturnsDetails_Returns200WithCreatedBook() + { + var mockHardcover = Substitute.For(); + mockHardcover.GetBookDetailsAsync(999).Returns(new HardcoverBookDetails + { + Id = 999, + Title = "Dune", + Year = 1965, + Publisher = "Ace Books", + Pages = 412, + Authors = ["Frank Herbert"], + Genres = ["Science Fiction"], + Isbn = "9780441013593", + CoverColor = "#c4a35a", + }); + + using var client = _factory + .WithWebHostBuilder(b => b.ConfigureServices(services => + { + services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService))); + services.AddScoped(_ => mockHardcover); + })) + .CreateClient(); + + var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 999 }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var book = await response.Content.ReadFromJsonAsync(); + book.Should().NotBeNull(); + book!.Title.Should().Be("Dune"); + book.HardcoverId.Should().Be(999); + book.Authors.Should().ContainSingle(a => a.Name == "Frank Herbert"); + } + + [Fact] + public async Task CreateBook_HardcoverReturnsNull_Returns404() + { + var mockHardcover = Substitute.For(); + mockHardcover.GetBookDetailsAsync(Arg.Any()).Returns((HardcoverBookDetails?)null); + + using var client = _factory + .WithWebHostBuilder(b => b.ConfigureServices(services => + { + services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService))); + services.AddScoped(_ => mockHardcover); + })) + .CreateClient(); + + var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 0 }); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task CreateBook_HardcoverIdAlreadyInDb_Returns200WithExistingBook() + { + await SeedBookWithHardcoverIdAsync(hardcoverId: 777, title: "Already Here"); + + var mockHardcover = Substitute.For(); + + using var client = _factory + .WithWebHostBuilder(b => b.ConfigureServices(services => + { + services.Remove(services.Single(d => d.ServiceType == typeof(IHardcoverService))); + services.AddScoped(_ => mockHardcover); + })) + .CreateClient(); + + var response = await client.PostAsJsonAsync("/api/books", new { hardcoverId = 777 }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var book = await response.Content.ReadFromJsonAsync(); + book!.Title.Should().Be("Already Here"); + await mockHardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any()); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task SeedBookAsync( + string title = "Test Book", + string[] formats = null!, + string[] genres = null!, + string? authorName = null) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var book = new Book + { + Title = title, + Formats = formats ?? [], + Genres = genres ?? [], + }; + db.Books.Add(book); + + if (authorName is not null) + { + var author = new Author { Name = authorName }; + db.Authors.Add(author); + await db.SaveChangesAsync(); + + db.BookAuthors.Add(new BookAuthor + { + BookId = book.Id, + AuthorId = author.Id, + }); + } + + await db.SaveChangesAsync(); + return book.Id; + } + + private async Task SeedBookWithHardcoverIdAsync(int hardcoverId, string title) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Books.Add(new Book { Title = title, HardcoverId = hardcoverId, Formats = [], Genres = [] }); + await db.SaveChangesAsync(); + } +} diff --git a/PageManager.Api/PageManager.Api.Tests/Integration/Fixtures/PostgresFixture.cs b/PageManager.Api/PageManager.Api.Tests/Integration/Fixtures/PostgresFixture.cs new file mode 100644 index 0000000..f9b5f15 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Integration/Fixtures/PostgresFixture.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data; +using Testcontainers.PostgreSql; + +namespace PageManager.Api.Tests.Integration.Fixtures; + +public class PostgresFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:17-alpine") + .Build(); + + public string ConnectionString => _container.GetConnectionString(); + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + var options = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseSnakeCaseNamingConvention() + .Options; + + await using var db = new AppDbContext(options); + await db.Database.MigrateAsync(); + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } +} + +[CollectionDefinition("Postgres")] +public class PostgresCollection : ICollectionFixture { } diff --git a/PageManager.Api/PageManager.Api.Tests/Integration/Fixtures/TestWebAppFactory.cs b/PageManager.Api/PageManager.Api.Tests/Integration/Fixtures/TestWebAppFactory.cs new file mode 100644 index 0000000..c7aa633 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Integration/Fixtures/TestWebAppFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using PageManager.Api.Data; + +namespace PageManager.Api.Tests.Integration.Fixtures; + +public class TestWebAppFactory(PostgresFixture postgres) : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + var descriptor = services.SingleOrDefault( + d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor is not null) + services.Remove(descriptor); + + services.AddDbContext(options => + options.UseNpgsql(postgres.ConnectionString) + .UseSnakeCaseNamingConvention()); + }); + } +} diff --git a/PageManager.Api/PageManager.Api.Tests/Integration/ImportControllerTests.cs b/PageManager.Api/PageManager.Api.Tests/Integration/ImportControllerTests.cs new file mode 100644 index 0000000..b9c1f4c --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Integration/ImportControllerTests.cs @@ -0,0 +1,158 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Data; +using PageManager.Api.Data.Models; +using PageManager.Api.Tests.Integration.Fixtures; + +namespace PageManager.Api.Tests.Integration; + +[Collection("Postgres")] +public class ImportControllerTests : IAsyncLifetime +{ + private readonly TestWebAppFactory _factory; + private readonly HttpClient _client; + + public ImportControllerTests(PostgresFixture postgres) + { + _factory = new TestWebAppFactory(postgres); + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions + .ExecuteSqlRawAsync(db.Database, + "TRUNCATE import_queue_items, import_sources RESTART IDENTITY CASCADE"); + } + + public Task DisposeAsync() + { + _client.Dispose(); + _factory.Dispose(); + return Task.CompletedTask; + } + + // ── GET /api/sources ────────────────────────────────────────────────────── + + [Fact] + public async Task GetSources_EmptyDb_Returns200WithEmptyArray() + { + var response = await _client.GetAsync("/api/sources"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var sources = await response.Content.ReadFromJsonAsync(); + sources.Should().BeEmpty(); + } + + [Fact] + public async Task GetSources_SeededSource_ReturnsMappedDto() + { + await SeedSourceAsync(name: "My Library", type: ImportSourceType.Folder, path: "/books"); + + var response = await _client.GetAsync("/api/sources"); + var sources = await response.Content.ReadFromJsonAsync(); + + sources.Should().HaveCount(1); + sources![0].Name.Should().Be("My Library"); + sources[0].Type.Should().Be("folder"); + sources[0].Enabled.Should().BeTrue(); + } + + // ── PATCH /api/sources/{id} ─────────────────────────────────────────────── + + [Fact] + public async Task UpdateSource_Found_Returns200WithUpdatedEnabled() + { + var id = await SeedSourceAsync(); + + var response = await _client.PatchAsJsonAsync($"/api/sources/{id}", new { enabled = false }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var dto = await response.Content.ReadFromJsonAsync(); + dto!.Enabled.Should().BeFalse(); + } + + [Fact] + public async Task UpdateSource_NotFound_Returns404() + { + var response = await _client.PatchAsJsonAsync("/api/sources/missing-id", new { enabled = false }); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── GET /api/queue ──────────────────────────────────────────────────────── + + [Fact] + public async Task GetQueue_EmptyDb_Returns200WithEmptyArray() + { + var response = await _client.GetAsync("/api/queue"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var items = await response.Content.ReadFromJsonAsync(); + items.Should().BeEmpty(); + } + + // ── DELETE /api/queue/{id} ──────────────────────────────────────────────── + + [Fact] + public async Task RemoveQueueItem_Found_Returns204() + { + var id = await SeedQueueItemAsync(); + var response = await _client.DeleteAsync($"/api/queue/{id}"); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task RemoveQueueItem_NotFound_Returns404() + { + var response = await _client.DeleteAsync("/api/queue/missing-id"); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── POST /api/queue/{id}/retry ──────────────────────────────────────────── + + [Fact] + public async Task RetryQueueItem_FailedItem_Returns204() + { + var id = await SeedQueueItemAsync(status: QueueItemStatus.Failed); + var response = await _client.PostAsync($"/api/queue/{id}/retry", null); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + } + + [Fact] + public async Task RetryQueueItem_NonFailedItem_Returns404() + { + var id = await SeedQueueItemAsync(status: QueueItemStatus.Completed); + var response = await _client.PostAsync($"/api/queue/{id}/retry", null); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private async Task SeedSourceAsync( + string name = "Test Source", + ImportSourceType type = ImportSourceType.Folder, + string path = "/test") + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var source = new ImportSource { Name = name, Type = type, Path = path }; + db.ImportSources.Add(source); + await db.SaveChangesAsync(); + return source.Id; + } + + private async Task SeedQueueItemAsync( + string filename = "book.epub", + QueueItemStatus status = QueueItemStatus.Queued) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var item = new ImportQueueItem { Filename = filename, Status = status, Source = "https://example.com" }; + db.ImportQueueItems.Add(item); + await db.SaveChangesAsync(); + return item.Id; + } +} diff --git a/PageManager.Api/PageManager.Api.Tests/PageManager.Api.Tests.csproj b/PageManager.Api/PageManager.Api.Tests/PageManager.Api.Tests.csproj new file mode 100644 index 0000000..6e6b6b6 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/PageManager.Api.Tests.csproj @@ -0,0 +1,26 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/PageManager.Api/PageManager.Api.Tests/Unit/Services/BooksServiceTests.cs b/PageManager.Api/PageManager.Api.Tests/Unit/Services/BooksServiceTests.cs new file mode 100644 index 0000000..0cedcb6 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Unit/Services/BooksServiceTests.cs @@ -0,0 +1,248 @@ +using FluentAssertions; +using NSubstitute; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Data.Repositories; +using PageManager.Api.Services; +using PageManager.Api.Tests.Helpers; + +namespace PageManager.Api.Tests.Unit.Services; + +public class BooksServiceTests +{ + private readonly IBooksRepository _repo = Substitute.For(); + private readonly IHardcoverService _hardcover = Substitute.For(); + private readonly BooksService _sut; + + public BooksServiceTests() + { + _sut = new BooksService(_repo, _hardcover); + } + + // ── GetAllAsync ─────────────────────────────────────────────────────────── + + [Fact] + public async Task GetAllAsync_MultipleBooks_ReturnsMappedDtos() + { + var books = new[] + { + BookFactory.Create(id: 1, title: "Book One").WithAuthors((1, "Author A")), + BookFactory.Create(id: 2, title: "Book Two").WithAuthors((2, "Author B")), + }; + _repo.GetAllAsync().Returns(books); + + var result = (await _sut.GetAllAsync()).ToArray(); + + result.Should().HaveCount(2); + result[0].Title.Should().Be("Book One"); + result[1].Title.Should().Be("Book Two"); + } + + [Fact] + public async Task GetAllAsync_EmptyRepo_ReturnsEmptyCollection() + { + _repo.GetAllAsync().Returns([]); + + var result = await _sut.GetAllAsync(); + + result.Should().BeEmpty(); + } + + // ── GetByIdAsync ────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_BookExists_ReturnsDtoWithCorrectFields() + { + var book = BookFactory.Create(id: 42, title: "Found Book", year: 2020, publisher: "Pub", pages: 100) + .WithAuthors((7, "Jane Doe")) + .WithSeries(seriesName: "Great Series", position: 2.0, arc: "Part One"); + _repo.GetByIdAsync(42).Returns(book); + + var result = await _sut.GetByIdAsync(42); + + result.Should().NotBeNull(); + result!.Id.Should().Be(42); + result.Title.Should().Be("Found Book"); + result.Year.Should().Be(2020); + result.Publisher.Should().Be("Pub"); + result.Pages.Should().Be(100); + result.Authors.Should().ContainSingle(a => a.Id == 7 && a.Name == "Jane Doe"); + result.Series.Should().NotBeNull(); + result.Series!.Name.Should().Be("Great Series"); + result.Series.Position.Should().Be(2.0); + result.Series.Arc.Should().Be("Part One"); + } + + [Fact] + public async Task GetByIdAsync_BookNotFound_ReturnsNull() + { + _repo.GetByIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null); + + var result = await _sut.GetByIdAsync(99); + + result.Should().BeNull(); + } + + // ── UpdateAsync ─────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateAsync_BookExists_AppliesAllFieldsAndSaves() + { + var book = BookFactory.Create(id: 5).WithAuthors((1, "Author")); + _repo.GetByIdAsync(5).Returns(book); + + var req = new UpdateBookRequest + { + Title = "Updated Title", + Year = 2025, + Publisher = "New Publisher", + Pages = 999, + Description = "New description", + Formats = ["epub", "mobi"], + Color = "#ff0000", + Genres = ["Fantasy", "Adventure"], + CoverUrl = "https://example.com/cover.jpg", + Isbn = "978-0-00-000000-0", + HardcoverId = 123, + }; + + var result = await _sut.UpdateAsync(5, req); + + result.Should().NotBeNull(); + result!.Title.Should().Be("Updated Title"); + result.Year.Should().Be(2025); + result.Publisher.Should().Be("New Publisher"); + result.Pages.Should().Be(999); + result.Description.Should().Be("New description"); + result.Formats.Should().BeEquivalentTo(["epub", "mobi"]); + result.Color.Should().Be("#ff0000"); + result.Genres.Should().BeEquivalentTo(["Fantasy", "Adventure"]); + result.CoverUrl.Should().Be("https://example.com/cover.jpg"); + result.Isbn.Should().Be("978-0-00-000000-0"); + result.HardcoverId.Should().Be(123); + await _repo.Received(1).SaveAsync(); + } + + [Fact] + public async Task UpdateAsync_BookNotFound_ReturnsNullWithoutSaving() + { + _repo.GetByIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null); + + var result = await _sut.UpdateAsync(99, new UpdateBookRequest()); + + result.Should().BeNull(); + await _repo.DidNotReceive().SaveAsync(); + } + + // ── DTO mapping ─────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByIdAsync_BookWithSeries_MapsSeriesNamePositionArc() + { + var book = BookFactory.Create(id: 1) + .WithAuthors((1, "Author")) + .WithSeries(seriesId: 10, seriesName: "The Stormlight Archive", position: 1.0, arc: "Book One"); + _repo.GetByIdAsync(1).Returns(book); + + var result = await _sut.GetByIdAsync(1); + + result!.Series.Should().NotBeNull(); + result.Series!.Name.Should().Be("The Stormlight Archive"); + result.Series.Position.Should().Be(1.0); + result.Series.Arc.Should().Be("Book One"); + } + + [Fact] + public async Task GetByIdAsync_BookWithoutSeries_SeriesIsNull() + { + var book = BookFactory.Create(id: 2).WithAuthors((1, "Author")); + _repo.GetByIdAsync(2).Returns(book); + + var result = await _sut.GetByIdAsync(2); + + result!.Series.Should().BeNull(); + } + + [Fact] + public async Task GetByIdAsync_BookWithMultipleAuthors_MapsAllAuthors() + { + var book = BookFactory.Create(id: 3) + .WithAuthors((1, "Alice"), (2, "Bob"), (3, "Carol")); + _repo.GetByIdAsync(3).Returns(book); + + var result = await _sut.GetByIdAsync(3); + + result!.Authors.Should().HaveCount(3); + result.Authors.Select(a => a.Name).Should().BeEquivalentTo(["Alice", "Bob", "Carol"]); + } + + // ── CreateFromHardcoverAsync ────────────────────────────────────────────── + + [Fact] + public async Task CreateFromHardcoverAsync_AlreadyInDb_ReturnsExistingWithoutCallingHardcover() + { + var existing = BookFactory.Create(id: 7, title: "Existing").WithAuthors((1, "Author")); + _repo.FindByHardcoverIdAsync(42).Returns(existing); + + var result = await _sut.CreateFromHardcoverAsync(42); + + result.Should().NotBeNull(); + result!.Id.Should().Be(7); + result.Title.Should().Be("Existing"); + await _hardcover.DidNotReceive().GetBookDetailsAsync(Arg.Any()); + } + + [Fact] + public async Task CreateFromHardcoverAsync_HardcoverReturnsNull_ReturnsNull() + { + _repo.FindByHardcoverIdAsync(99).Returns((PageManager.Api.Data.Models.Book?)null); + _hardcover.GetBookDetailsAsync(99).Returns((HardcoverBookDetails?)null); + + var result = await _sut.CreateFromHardcoverAsync(99); + + result.Should().BeNull(); + await _repo.DidNotReceive().CreateBookAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any<(string, double, string?)?> ()); + } + + [Fact] + public async Task CreateFromHardcoverAsync_Success_CreatesBookAndReturnsDto() + { + _repo.FindByHardcoverIdAsync(123).Returns((PageManager.Api.Data.Models.Book?)null); + + var details = new HardcoverBookDetails + { + Id = 123, + Title = "Dune", + Year = 1965, + Publisher = "Ace Books", + Pages = 412, + Description = "A sci-fi classic.", + Authors = ["Frank Herbert"], + Genres = ["Science Fiction"], + Isbn = "9780441013593", + CoverUrl = "https://example.com/cover.jpg", + CoverColor = "#c4a35a", + Series = new HardcoverSeriesInfo { Name = "Dune Chronicles", Position = 1.0 }, + }; + _hardcover.GetBookDetailsAsync(123).Returns(details); + + var createdBook = BookFactory.Create(id: 55, title: "Dune") + .WithAuthors((1, "Frank Herbert")) + .WithSeries(seriesName: "Dune Chronicles", position: 1.0); + _repo.CreateBookAsync(Arg.Any(), Arg.Any>(), Arg.Any<(string, double, string?)?>()) + .Returns(createdBook); + + var result = await _sut.CreateFromHardcoverAsync(123); + + result.Should().NotBeNull(); + result!.Title.Should().Be("Dune"); + result.Series.Should().NotBeNull(); + result.Series!.Name.Should().Be("Dune Chronicles"); + await _repo.Received(1).CreateBookAsync( + Arg.Is(b => b.HardcoverId == 123 && b.Title == "Dune"), + Arg.Is>(a => a.Contains("Frank Herbert")), + Arg.Is<(string, double, string?)?>(si => si != null && si.Value.Item1 == "Dune Chronicles")); + } +} diff --git a/PageManager.Api/PageManager.Api.Tests/Unit/Services/ImportServiceTests.cs b/PageManager.Api/PageManager.Api.Tests/Unit/Services/ImportServiceTests.cs new file mode 100644 index 0000000..fad6d55 --- /dev/null +++ b/PageManager.Api/PageManager.Api.Tests/Unit/Services/ImportServiceTests.cs @@ -0,0 +1,123 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data; +using PageManager.Api.Data.Models; +using PageManager.Api.Services; + +namespace PageManager.Api.Tests.Unit.Services; + +public class ImportServiceTests : IDisposable +{ + private readonly AppDbContext _db; + private readonly ImportService _sut; + + public ImportServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + _db = new AppDbContext(options); + _sut = new ImportService(_db); + } + + public void Dispose() => _db.Dispose(); + + // ── Sources ─────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetSourcesAsync_ReturnsMappedDtos() + { + _db.ImportSources.Add(new ImportSource { Id = "s1", Name = "My Books", Type = ImportSourceType.Folder, Path = "/books", Enabled = true }); + await _db.SaveChangesAsync(); + + var result = (await _sut.GetSourcesAsync()).ToArray(); + + result.Should().HaveCount(1); + result[0].Id.Should().Be("s1"); + result[0].Name.Should().Be("My Books"); + result[0].Type.Should().Be("folder"); + result[0].Enabled.Should().BeTrue(); + } + + [Fact] + public async Task UpdateSourceAsync_Found_TogglesEnabled() + { + _db.ImportSources.Add(new ImportSource { Id = "s1", Name = "Lib", Type = ImportSourceType.Folder, Path = "/", Enabled = true }); + await _db.SaveChangesAsync(); + + var result = await _sut.UpdateSourceAsync("s1", false); + + result.Should().NotBeNull(); + result!.Enabled.Should().BeFalse(); + (await _db.ImportSources.FindAsync("s1"))!.Enabled.Should().BeFalse(); + } + + [Fact] + public async Task UpdateSourceAsync_NotFound_ReturnsNull() + { + var result = await _sut.UpdateSourceAsync("missing", true); + result.Should().BeNull(); + } + + // ── Queue ───────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetQueueAsync_ReturnsMappedDtos() + { + _db.ImportQueueItems.Add(new ImportQueueItem + { + Id = "q1", Filename = "book.epub", SizeBytes = 1024, DownloadedBytes = 512, + Status = QueueItemStatus.Downloading, Source = "https://example.com", + }); + await _db.SaveChangesAsync(); + + var result = (await _sut.GetQueueAsync()).ToArray(); + + result.Should().HaveCount(1); + result[0].Id.Should().Be("q1"); + result[0].Status.Should().Be("downloading"); + } + + [Fact] + public async Task RemoveQueueItemAsync_Found_RemovesAndReturnsTrue() + { + _db.ImportQueueItems.Add(new ImportQueueItem { Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Completed }); + await _db.SaveChangesAsync(); + + var result = await _sut.RemoveQueueItemAsync("q1"); + + result.Should().BeTrue(); + _db.ImportQueueItems.Should().BeEmpty(); + } + + [Fact] + public async Task RemoveQueueItemAsync_NotFound_ReturnsFalse() + { + (await _sut.RemoveQueueItemAsync("missing")).Should().BeFalse(); + } + + [Fact] + public async Task RetryQueueItemAsync_FailedItem_SetsStatusToQueued() + { + _db.ImportQueueItems.Add(new ImportQueueItem + { + Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Failed, Error = "timeout", + }); + await _db.SaveChangesAsync(); + + var result = await _sut.RetryQueueItemAsync("q1"); + + result.Should().BeTrue(); + var item = await _db.ImportQueueItems.FindAsync("q1"); + item!.Status.Should().Be(QueueItemStatus.Queued); + item.Error.Should().BeNull(); + } + + [Fact] + public async Task RetryQueueItemAsync_NonFailedItem_ReturnsFalse() + { + _db.ImportQueueItems.Add(new ImportQueueItem { Id = "q1", Filename = "f.epub", Status = QueueItemStatus.Completed }); + await _db.SaveChangesAsync(); + + (await _sut.RetryQueueItemAsync("q1")).Should().BeFalse(); + } +} diff --git a/PageManager.Api/PageManager.Api.slnx b/PageManager.Api/PageManager.Api.slnx new file mode 100644 index 0000000..335e3fa --- /dev/null +++ b/PageManager.Api/PageManager.Api.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/AuthorDto.cs b/PageManager.Api/PageManager.Api/Api/Dtos/AuthorDto.cs new file mode 100644 index 0000000..a16faaa --- /dev/null +++ b/PageManager.Api/PageManager.Api/Api/Dtos/AuthorDto.cs @@ -0,0 +1,7 @@ +namespace PageManager.Api.Api.Dtos; + +public class AuthorDto +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; +} diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs b/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs new file mode 100644 index 0000000..c21f038 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Api/Dtos/BookDto.cs @@ -0,0 +1,26 @@ +namespace PageManager.Api.Api.Dtos; + +public class BookSeriesDto +{ + public string Name { get; set; } = string.Empty; + public double Position { get; set; } + public string? Arc { get; set; } +} + +public class BookDto +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public int? Year { get; set; } + public string? Publisher { get; set; } + public int? Pages { get; set; } + public string? Description { get; set; } + public string[] Formats { get; set; } = []; + public string Color { get; set; } = "#6366f1"; + public string[] Genres { get; set; } = []; + public AuthorDto[] Authors { get; set; } = []; + public BookSeriesDto? Series { get; set; } + public string? CoverUrl { get; set; } + public string? Isbn { get; set; } + public int? HardcoverId { get; set; } +} diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/HardcoverDtos.cs b/PageManager.Api/PageManager.Api/Api/Dtos/HardcoverDtos.cs new file mode 100644 index 0000000..600dd21 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Api/Dtos/HardcoverDtos.cs @@ -0,0 +1,37 @@ +namespace PageManager.Api.Api.Dtos; + +public class HardcoverBookResult +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string[] Authors { get; set; } = []; + public int? Year { get; set; } + public string[] Genres { get; set; } = []; +} + +public class HardcoverBookDetails +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public int? Pages { get; set; } + public int? Year { get; set; } + public string[] Authors { get; set; } = []; + public string[] Genres { get; set; } = []; + public string? Isbn { get; set; } + public string? Publisher { get; set; } + public string? CoverUrl { get; set; } + public string? CoverColor { get; set; } + public HardcoverSeriesInfo? Series { get; set; } +} + +public class HardcoverSeriesInfo +{ + public string Name { get; set; } = string.Empty; + public double Position { get; set; } +} + +public class CreateBookFromHardcoverRequest +{ + public int HardcoverId { get; set; } +} diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/ImportDtos.cs b/PageManager.Api/PageManager.Api/Api/Dtos/ImportDtos.cs new file mode 100644 index 0000000..7572847 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Api/Dtos/ImportDtos.cs @@ -0,0 +1,26 @@ +namespace PageManager.Api.Api.Dtos; + +public class ImportSourceDto +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public bool Enabled { get; set; } +} + +public class UpdateSourceRequest +{ + public bool Enabled { get; set; } +} + +public class QueueItemDto +{ + public string Id { get; set; } = string.Empty; + public string Filename { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public long DownloadedBytes { get; set; } + public string Status { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? Error { get; set; } +} diff --git a/PageManager.Api/PageManager.Api/Api/Dtos/UpdateBookRequest.cs b/PageManager.Api/PageManager.Api/Api/Dtos/UpdateBookRequest.cs new file mode 100644 index 0000000..3399a42 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Api/Dtos/UpdateBookRequest.cs @@ -0,0 +1,16 @@ +namespace PageManager.Api.Api.Dtos; + +public class UpdateBookRequest +{ + public string Title { get; set; } = string.Empty; + public int? Year { get; set; } + public string? Publisher { get; set; } + public int? Pages { get; set; } + public string? Description { get; set; } + public string[] Formats { get; set; } = []; + public string Color { get; set; } = "#6366f1"; + public string[] Genres { get; set; } = []; + public string? CoverUrl { get; set; } + public string? Isbn { get; set; } + public int? HardcoverId { get; set; } +} diff --git a/PageManager.Api/PageManager.Api/Controllers/BooksController.cs b/PageManager.Api/PageManager.Api/Controllers/BooksController.cs new file mode 100644 index 0000000..001096c --- /dev/null +++ b/PageManager.Api/PageManager.Api/Controllers/BooksController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Services; + +namespace PageManager.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class BooksController(IBooksService books) : ControllerBase +{ + [HttpGet] + public async Task> GetBooks() => + await books.GetAllAsync(); + + [HttpGet("{id}")] + public async Task> GetBook(int id) + { + var book = await books.GetByIdAsync(id); + return book is null ? NotFound() : Ok(book); + } + + [HttpPost] + public async Task> CreateBook(CreateBookFromHardcoverRequest req) + { + var book = await books.CreateFromHardcoverAsync(req.HardcoverId); + return book is null ? NotFound() : Ok(book); + } + + [HttpPut("{id}")] + public async Task> UpdateBook(int id, UpdateBookRequest req) + { + var book = await books.UpdateAsync(id, req); + return book is null ? NotFound() : Ok(book); + } +} diff --git a/PageManager.Api/PageManager.Api/Controllers/QueueController.cs b/PageManager.Api/PageManager.Api/Controllers/QueueController.cs new file mode 100644 index 0000000..685ba7a --- /dev/null +++ b/PageManager.Api/PageManager.Api/Controllers/QueueController.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Services; + +namespace PageManager.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class QueueController(IImportService import) : ControllerBase +{ + [HttpGet] + public async Task> GetQueue() => + await import.GetQueueAsync(); + + [HttpDelete("{id}")] + public async Task RemoveItem(string id) => + await import.RemoveQueueItemAsync(id) ? NoContent() : NotFound(); + + [HttpPost("{id}/retry")] + public async Task RetryItem(string id) => + await import.RetryQueueItemAsync(id) ? NoContent() : NotFound(); +} diff --git a/PageManager.Api/PageManager.Api/Controllers/SearchController.cs b/PageManager.Api/PageManager.Api/Controllers/SearchController.cs new file mode 100644 index 0000000..2f03a71 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Controllers/SearchController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Services; + +namespace PageManager.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class SearchController(IHardcoverService hardcover) : ControllerBase +{ + [HttpGet("books")] + public async Task>> SearchBooks([FromQuery] string q) + { + if (string.IsNullOrWhiteSpace(q)) + return BadRequest("Query parameter 'q' is required."); + + var results = await hardcover.SearchBooksAsync(q); + return Ok(results); + } +} diff --git a/PageManager.Api/PageManager.Api/Controllers/SourcesController.cs b/PageManager.Api/PageManager.Api/Controllers/SourcesController.cs new file mode 100644 index 0000000..c5cef96 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Controllers/SourcesController.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Services; + +namespace PageManager.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class SourcesController(IImportService import) : ControllerBase +{ + [HttpGet] + public async Task> GetSources() => + await import.GetSourcesAsync(); + + [HttpPatch("{id}")] + public async Task> UpdateSource(string id, UpdateSourceRequest req) + { + var result = await import.UpdateSourceAsync(id, req.Enabled); + return result is null ? NotFound() : Ok(result); + } +} diff --git a/PageManager.Api/PageManager.Api/Data/AppDbContext.cs b/PageManager.Api/PageManager.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..7404655 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/AppDbContext.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Books => Set(); + public DbSet Authors => Set(); + public DbSet Series => Set(); + public DbSet BookAuthors => Set(); + public DbSet SeriesEntries => Set(); + public DbSet ImportSources => Set(); + public DbSet ImportQueueItems => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + // ── BookAuthor (composite PK) ──────────────────────────────────────── + model.Entity(e => + { + e.HasKey(ba => new { ba.BookId, ba.AuthorId }); + + e.HasOne(ba => ba.Book) + .WithMany(b => b.BookAuthors) + .HasForeignKey(ba => ba.BookId); + + e.HasOne(ba => ba.Author) + .WithMany(a => a.BookAuthors) + .HasForeignKey(ba => ba.AuthorId); + + e.Property(ba => ba.Role) + .HasConversion(); + }); + + // ── SeriesEntry (composite PK) ─────────────────────────────────────── + model.Entity(e => + { + e.HasKey(se => new { se.SeriesId, se.BookId }); + + e.HasOne(se => se.Series) + .WithMany(s => s.Entries) + .HasForeignKey(se => se.SeriesId); + + e.HasOne(se => se.Book) + .WithMany(b => b.SeriesEntries) + .HasForeignKey(se => se.BookId); + }); + + // ── Series ─────────────────────────────────────────────────────────── + model.Entity(e => + { + e.Property(s => s.Type) + .HasConversion(); + }); + + // ── Book ───────────────────────────────────────────────────────────── + model.Entity(e => + { + e.Property(b => b.Title).IsRequired(); + + // Formats and Genres are stored as native PostgreSQL text[] columns. + e.Property(b => b.Formats).HasColumnType("text[]"); + e.Property(b => b.Genres).HasColumnType("text[]"); + + e.HasIndex(b => b.HardcoverId); + e.HasIndex(b => b.Isbn); + }); + + // ── Author ─────────────────────────────────────────────────────────── + model.Entity(e => + { + e.Property(a => a.Name).IsRequired(); + }); + + // ── ImportSource ────────────────────────────────────────────────────── + model.Entity(e => + { + e.Property(s => s.Id).ValueGeneratedNever(); + e.Property(s => s.Type).HasConversion(); + }); + + // ── ImportQueueItem ─────────────────────────────────────────────────── + model.Entity(e => + { + e.Property(i => i.Id).ValueGeneratedNever(); + e.Property(i => i.Status).HasConversion(); + }); + } +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/Author.cs b/PageManager.Api/PageManager.Api/Data/Models/Author.cs new file mode 100644 index 0000000..93b9b0d --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/Author.cs @@ -0,0 +1,9 @@ +namespace PageManager.Api.Data.Models; + +public class Author +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + + public ICollection BookAuthors { get; set; } = []; +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/Book.cs b/PageManager.Api/PageManager.Api/Data/Models/Book.cs new file mode 100644 index 0000000..ee24efe --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/Book.cs @@ -0,0 +1,30 @@ +namespace PageManager.Api.Data.Models; + +public class Book +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public int? Year { get; set; } + public string? Publisher { get; set; } + public int? Pages { get; set; } + public string? Description { get; set; } + + /// Hex color used as cover placeholder when CoverUrl is absent. + public string Color { get; set; } = "#6366f1"; + public string? CoverUrl { get; set; } + + /// ISBN-13 sourced from Hardcover. + public string? Isbn { get; set; } + + /// Hardcover book ID — used to re-fetch metadata without a search round-trip. + public int? HardcoverId { get; set; } + + /// File formats present in the library (e.g. epub, mobi, pdf). Stored as text[]. + public string[] Formats { get; set; } = []; + + /// Genre tags sourced from Hardcover cached_tags. Stored as text[]. + public string[] Genres { get; set; } = []; + + public ICollection BookAuthors { get; set; } = []; + public ICollection SeriesEntries { get; set; } = []; +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/BookAuthor.cs b/PageManager.Api/PageManager.Api/Data/Models/BookAuthor.cs new file mode 100644 index 0000000..d43f2c4 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/BookAuthor.cs @@ -0,0 +1,14 @@ +namespace PageManager.Api.Data.Models; + +public enum AuthorRole { Author, Editor } + +public class BookAuthor +{ + public int BookId { get; set; } + public Book Book { get; set; } = null!; + + public int AuthorId { get; set; } + public Author Author { get; set; } = null!; + + public AuthorRole Role { get; set; } = AuthorRole.Author; +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/ImportQueueItem.cs b/PageManager.Api/PageManager.Api/Data/Models/ImportQueueItem.cs new file mode 100644 index 0000000..6b2ea55 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/ImportQueueItem.cs @@ -0,0 +1,14 @@ +namespace PageManager.Api.Data.Models; + +public enum QueueItemStatus { Queued, Downloading, Completed, Failed } + +public class ImportQueueItem +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Filename { get; set; } = string.Empty; + public long SizeBytes { get; set; } + public long DownloadedBytes { get; set; } + public QueueItemStatus Status { get; set; } = QueueItemStatus.Queued; + public string Source { get; set; } = string.Empty; + public string? Error { get; set; } +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/ImportSource.cs b/PageManager.Api/PageManager.Api/Data/Models/ImportSource.cs new file mode 100644 index 0000000..6d78af7 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/ImportSource.cs @@ -0,0 +1,12 @@ +namespace PageManager.Api.Data.Models; + +public enum ImportSourceType { Folder, Calibre, Opds, Url } + +public class ImportSource +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } = string.Empty; + public ImportSourceType Type { get; set; } = ImportSourceType.Folder; + public string Path { get; set; } = string.Empty; + public bool Enabled { get; set; } = true; +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/Series.cs b/PageManager.Api/PageManager.Api/Data/Models/Series.cs new file mode 100644 index 0000000..44414b2 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/Series.cs @@ -0,0 +1,12 @@ +namespace PageManager.Api.Data.Models; + +public enum SeriesType { SingleAuthor, MultiAuthor } + +public class Series +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public SeriesType Type { get; set; } = SeriesType.SingleAuthor; + + public ICollection Entries { get; set; } = []; +} diff --git a/PageManager.Api/PageManager.Api/Data/Models/SeriesEntry.cs b/PageManager.Api/PageManager.Api/Data/Models/SeriesEntry.cs new file mode 100644 index 0000000..ba15c40 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Models/SeriesEntry.cs @@ -0,0 +1,18 @@ +namespace PageManager.Api.Data.Models; + +public class SeriesEntry +{ + public int SeriesId { get; set; } + public Series Series { get; set; } = null!; + + public int BookId { get; set; } + public Book Book { get; set; } = null!; + + /// + /// Fractional position allows inserting novellas between whole-number entries (e.g. 1.5). + /// + public double Position { get; set; } + + /// Sub-arc name within the series (e.g. "The Way of Kings Prime"). + public string? Arc { get; set; } +} diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs new file mode 100644 index 0000000..f4d333b --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Repositories/BooksRepository.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Data.Repositories; + +public class BooksRepository(AppDbContext db) : IBooksRepository +{ + public Task> GetAllAsync() => + db.Books + .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) + .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .ToListAsync() + .ContinueWith(t => (IEnumerable)t.Result); + + public Task GetByIdAsync(int id) => + db.Books + .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) + .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .FirstOrDefaultAsync(b => b.Id == id); + + public Task FindByHardcoverIdAsync(int hardcoverId) => + db.Books + .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) + .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .FirstOrDefaultAsync(b => b.HardcoverId == hardcoverId); + + public async Task CreateBookAsync( + Book book, + IReadOnlyList authorNames, + (string name, double position, string? arc)? series) + { + db.Books.Add(book); + await db.SaveChangesAsync(); + + // Resolve / create authors + var authors = new List(); + foreach (var name in authorNames) + { + var author = await db.Authors.FirstOrDefaultAsync(a => a.Name == name) + ?? new Author { Name = name }; + if (author.Id == 0) db.Authors.Add(author); + authors.Add(author); + } + if (authors.Any(a => a.Id == 0)) await db.SaveChangesAsync(); + foreach (var author in authors) + db.BookAuthors.Add(new BookAuthor { BookId = book.Id, AuthorId = author.Id }); + + // Resolve / create series + if (series is { } si) + { + var s = await db.Series.FirstOrDefaultAsync(x => x.Name == si.name) + ?? new Series { Name = si.name }; + if (s.Id == 0) db.Series.Add(s); + if (s.Id == 0) await db.SaveChangesAsync(); + db.SeriesEntries.Add(new SeriesEntry + { + SeriesId = s.Id, + BookId = book.Id, + Position = si.position, + Arc = si.arc, + }); + } + + await db.SaveChangesAsync(); + + return await db.Books + .Include(b => b.BookAuthors).ThenInclude(ba => ba.Author) + .Include(b => b.SeriesEntries).ThenInclude(se => se.Series) + .FirstAsync(b => b.Id == book.Id); + } + + public Task SaveAsync() => db.SaveChangesAsync(); +} diff --git a/PageManager.Api/PageManager.Api/Data/Repositories/IBooksRepository.cs b/PageManager.Api/PageManager.Api/Data/Repositories/IBooksRepository.cs new file mode 100644 index 0000000..920abb1 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Data/Repositories/IBooksRepository.cs @@ -0,0 +1,15 @@ +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Data.Repositories; + +public interface IBooksRepository +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task FindByHardcoverIdAsync(int hardcoverId); + Task CreateBookAsync( + Book book, + IReadOnlyList authorNames, + (string name, double position, string? arc)? series); + Task SaveAsync(); +} diff --git a/PageManager.Api/PageManager.Api/Dockerfile b/PageManager.Api/PageManager.Api/Dockerfile new file mode 100644 index 0000000..64a5dbe --- /dev/null +++ b/PageManager.Api/PageManager.Api/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["PageManager.Api/PageManager.Api.csproj", "PageManager.Api/"] +RUN dotnet restore "PageManager.Api/PageManager.Api.csproj" +COPY . . +WORKDIR "/src/PageManager.Api" +RUN dotnet build "./PageManager.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./PageManager.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "PageManager.Api.dll"] diff --git a/PageManager.Api/PageManager.Api/Migrations/20260315170137_InitialMigration.Designer.cs b/PageManager.Api/PageManager.Api/Migrations/20260315170137_InitialMigration.Designer.cs new file mode 100644 index 0000000..359489d --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260315170137_InitialMigration.Designer.cs @@ -0,0 +1,253 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260315170137_InitialMigration")] + partial class InitialMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasColumnType("text") + .HasColumnName("color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.PrimitiveCollection("Formats") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("formats"); + + b.PrimitiveCollection("Genres") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("genres"); + + b.Property("HardcoverId") + .HasColumnType("integer") + .HasColumnName("hardcover_id"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Pages") + .HasColumnType("integer") + .HasColumnName("pages"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_books"); + + b.HasIndex("HardcoverId") + .HasDatabaseName("ix_books_hardcover_id"); + + b.HasIndex("Isbn") + .HasDatabaseName("ix_books_isbn"); + + b.ToTable("books", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("AuthorId") + .HasColumnType("integer") + .HasColumnName("author_id"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.HasKey("BookId", "AuthorId") + .HasName("pk_book_authors"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_book_authors_author_id"); + + b.ToTable("book_authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_series"); + + b.ToTable("series", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.Property("SeriesId") + .HasColumnType("integer") + .HasColumnName("series_id"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("Arc") + .HasColumnType("text") + .HasColumnName("arc"); + + b.Property("Position") + .HasColumnType("double precision") + .HasColumnName("position"); + + b.HasKey("SeriesId", "BookId") + .HasName("pk_series_entries"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_series_entries_book_id"); + + b.ToTable("series_entries", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.HasOne("PageManager.Api.Data.Models.Author", "Author") + .WithMany("BookAuthors") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_authors_author_id"); + + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("BookAuthors") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_books_book_id"); + + b.Navigation("Author"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("SeriesEntries") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_books_book_id"); + + b.HasOne("PageManager.Api.Data.Models.Series", "Series") + .WithMany("Entries") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_series_series_id"); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Navigation("BookAuthors"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Navigation("BookAuthors"); + + b.Navigation("SeriesEntries"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260315170137_InitialMigration.cs b/PageManager.Api/PageManager.Api/Migrations/20260315170137_InitialMigration.cs new file mode 100644 index 0000000..4193f48 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260315170137_InitialMigration.cs @@ -0,0 +1,155 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + /// + public partial class InitialMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "authors", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_authors", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "books", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + title = table.Column(type: "text", nullable: false), + year = table.Column(type: "integer", nullable: true), + publisher = table.Column(type: "text", nullable: true), + pages = table.Column(type: "integer", nullable: true), + description = table.Column(type: "text", nullable: true), + color = table.Column(type: "text", nullable: false), + cover_url = table.Column(type: "text", nullable: true), + isbn = table.Column(type: "text", nullable: true), + hardcover_id = table.Column(type: "integer", nullable: true), + formats = table.Column(type: "text[]", nullable: false), + genres = table.Column(type: "text[]", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_books", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "series", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "text", nullable: false), + type = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_series", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "book_authors", + columns: table => new + { + book_id = table.Column(type: "integer", nullable: false), + author_id = table.Column(type: "integer", nullable: false), + role = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_book_authors", x => new { x.book_id, x.author_id }); + table.ForeignKey( + name: "fk_book_authors_authors_author_id", + column: x => x.author_id, + principalTable: "authors", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_book_authors_books_book_id", + column: x => x.book_id, + principalTable: "books", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "series_entries", + columns: table => new + { + series_id = table.Column(type: "integer", nullable: false), + book_id = table.Column(type: "integer", nullable: false), + position = table.Column(type: "double precision", nullable: false), + arc = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_series_entries", x => new { x.series_id, x.book_id }); + table.ForeignKey( + name: "fk_series_entries_books_book_id", + column: x => x.book_id, + principalTable: "books", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_series_entries_series_series_id", + column: x => x.series_id, + principalTable: "series", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_book_authors_author_id", + table: "book_authors", + column: "author_id"); + + migrationBuilder.CreateIndex( + name: "ix_books_hardcover_id", + table: "books", + column: "hardcover_id"); + + migrationBuilder.CreateIndex( + name: "ix_books_isbn", + table: "books", + column: "isbn"); + + migrationBuilder.CreateIndex( + name: "ix_series_entries_book_id", + table: "series_entries", + column: "book_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "book_authors"); + + migrationBuilder.DropTable( + name: "series_entries"); + + migrationBuilder.DropTable( + name: "authors"); + + migrationBuilder.DropTable( + name: "books"); + + migrationBuilder.DropTable( + name: "series"); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260315184420_AddImportTables.Designer.cs b/PageManager.Api/PageManager.Api/Migrations/20260315184420_AddImportTables.Designer.cs new file mode 100644 index 0000000..11a05dd --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260315184420_AddImportTables.Designer.cs @@ -0,0 +1,253 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260315184420_AddImportTables")] + partial class AddImportTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasColumnType("text") + .HasColumnName("color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.PrimitiveCollection("Formats") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("formats"); + + b.PrimitiveCollection("Genres") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("genres"); + + b.Property("HardcoverId") + .HasColumnType("integer") + .HasColumnName("hardcover_id"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Pages") + .HasColumnType("integer") + .HasColumnName("pages"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_books"); + + b.HasIndex("HardcoverId") + .HasDatabaseName("ix_books_hardcover_id"); + + b.HasIndex("Isbn") + .HasDatabaseName("ix_books_isbn"); + + b.ToTable("books", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("AuthorId") + .HasColumnType("integer") + .HasColumnName("author_id"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.HasKey("BookId", "AuthorId") + .HasName("pk_book_authors"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_book_authors_author_id"); + + b.ToTable("book_authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_series"); + + b.ToTable("series", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.Property("SeriesId") + .HasColumnType("integer") + .HasColumnName("series_id"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("Arc") + .HasColumnType("text") + .HasColumnName("arc"); + + b.Property("Position") + .HasColumnType("double precision") + .HasColumnName("position"); + + b.HasKey("SeriesId", "BookId") + .HasName("pk_series_entries"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_series_entries_book_id"); + + b.ToTable("series_entries", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.HasOne("PageManager.Api.Data.Models.Author", "Author") + .WithMany("BookAuthors") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_authors_author_id"); + + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("BookAuthors") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_books_book_id"); + + b.Navigation("Author"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("SeriesEntries") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_books_book_id"); + + b.HasOne("PageManager.Api.Data.Models.Series", "Series") + .WithMany("Entries") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_series_series_id"); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Navigation("BookAuthors"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Navigation("BookAuthors"); + + b.Navigation("SeriesEntries"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260315184420_AddImportTables.cs b/PageManager.Api/PageManager.Api/Migrations/20260315184420_AddImportTables.cs new file mode 100644 index 0000000..da3a8e7 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260315184420_AddImportTables.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + /// + public partial class AddImportTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260315185207_PendingMigration.Designer.cs b/PageManager.Api/PageManager.Api/Migrations/20260315185207_PendingMigration.Designer.cs new file mode 100644 index 0000000..0bac134 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260315185207_PendingMigration.Designer.cs @@ -0,0 +1,323 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260315185207_PendingMigration")] + partial class PendingMigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasColumnType("text") + .HasColumnName("color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.PrimitiveCollection("Formats") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("formats"); + + b.PrimitiveCollection("Genres") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("genres"); + + b.Property("HardcoverId") + .HasColumnType("integer") + .HasColumnName("hardcover_id"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Pages") + .HasColumnType("integer") + .HasColumnName("pages"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_books"); + + b.HasIndex("HardcoverId") + .HasDatabaseName("ix_books_hardcover_id"); + + b.HasIndex("Isbn") + .HasDatabaseName("ix_books_isbn"); + + b.ToTable("books", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("AuthorId") + .HasColumnType("integer") + .HasColumnName("author_id"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.HasKey("BookId", "AuthorId") + .HasName("pk_book_authors"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_book_authors_author_id"); + + b.ToTable("book_authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("DownloadedBytes") + .HasColumnType("bigint") + .HasColumnName("downloaded_bytes"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text") + .HasColumnName("filename"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_import_queue_items"); + + b.ToTable("import_queue_items", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.ImportSource", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_import_sources"); + + b.ToTable("import_sources", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_series"); + + b.ToTable("series", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.Property("SeriesId") + .HasColumnType("integer") + .HasColumnName("series_id"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("Arc") + .HasColumnType("text") + .HasColumnName("arc"); + + b.Property("Position") + .HasColumnType("double precision") + .HasColumnName("position"); + + b.HasKey("SeriesId", "BookId") + .HasName("pk_series_entries"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_series_entries_book_id"); + + b.ToTable("series_entries", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.HasOne("PageManager.Api.Data.Models.Author", "Author") + .WithMany("BookAuthors") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_authors_author_id"); + + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("BookAuthors") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_books_book_id"); + + b.Navigation("Author"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("SeriesEntries") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_books_book_id"); + + b.HasOne("PageManager.Api.Data.Models.Series", "Series") + .WithMany("Entries") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_series_series_id"); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Navigation("BookAuthors"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Navigation("BookAuthors"); + + b.Navigation("SeriesEntries"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/20260315185207_PendingMigration.cs b/PageManager.Api/PageManager.Api/Migrations/20260315185207_PendingMigration.cs new file mode 100644 index 0000000..802a689 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/20260315185207_PendingMigration.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + /// + public partial class PendingMigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "import_queue_items", + columns: table => new + { + id = table.Column(type: "text", nullable: false), + filename = table.Column(type: "text", nullable: false), + size_bytes = table.Column(type: "bigint", nullable: false), + downloaded_bytes = table.Column(type: "bigint", nullable: false), + status = table.Column(type: "text", nullable: false), + source = table.Column(type: "text", nullable: false), + error = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_import_queue_items", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "import_sources", + columns: table => new + { + id = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + type = table.Column(type: "text", nullable: false), + path = table.Column(type: "text", nullable: false), + enabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_import_sources", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "import_queue_items"); + + migrationBuilder.DropTable( + name: "import_sources"); + } + } +} diff --git a/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs b/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..26161c0 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,320 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PageManager.Api.Data; + +#nullable disable + +namespace PageManager.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_authors"); + + b.ToTable("authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasColumnType("text") + .HasColumnName("color"); + + b.Property("CoverUrl") + .HasColumnType("text") + .HasColumnName("cover_url"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.PrimitiveCollection("Formats") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("formats"); + + b.PrimitiveCollection("Genres") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("genres"); + + b.Property("HardcoverId") + .HasColumnType("integer") + .HasColumnName("hardcover_id"); + + b.Property("Isbn") + .HasColumnType("text") + .HasColumnName("isbn"); + + b.Property("Pages") + .HasColumnType("integer") + .HasColumnName("pages"); + + b.Property("Publisher") + .HasColumnType("text") + .HasColumnName("publisher"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_books"); + + b.HasIndex("HardcoverId") + .HasDatabaseName("ix_books_hardcover_id"); + + b.HasIndex("Isbn") + .HasDatabaseName("ix_books_isbn"); + + b.ToTable("books", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("AuthorId") + .HasColumnType("integer") + .HasColumnName("author_id"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role"); + + b.HasKey("BookId", "AuthorId") + .HasName("pk_book_authors"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_book_authors_author_id"); + + b.ToTable("book_authors", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.ImportQueueItem", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("DownloadedBytes") + .HasColumnType("bigint") + .HasColumnName("downloaded_bytes"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("text") + .HasColumnName("filename"); + + b.Property("SizeBytes") + .HasColumnType("bigint") + .HasColumnName("size_bytes"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_import_queue_items"); + + b.ToTable("import_queue_items", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.ImportSource", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_import_sources"); + + b.ToTable("import_sources", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_series"); + + b.ToTable("series", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.Property("SeriesId") + .HasColumnType("integer") + .HasColumnName("series_id"); + + b.Property("BookId") + .HasColumnType("integer") + .HasColumnName("book_id"); + + b.Property("Arc") + .HasColumnType("text") + .HasColumnName("arc"); + + b.Property("Position") + .HasColumnType("double precision") + .HasColumnName("position"); + + b.HasKey("SeriesId", "BookId") + .HasName("pk_series_entries"); + + b.HasIndex("BookId") + .HasDatabaseName("ix_series_entries_book_id"); + + b.ToTable("series_entries", (string)null); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.BookAuthor", b => + { + b.HasOne("PageManager.Api.Data.Models.Author", "Author") + .WithMany("BookAuthors") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_authors_author_id"); + + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("BookAuthors") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_book_authors_books_book_id"); + + b.Navigation("Author"); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.SeriesEntry", b => + { + b.HasOne("PageManager.Api.Data.Models.Book", "Book") + .WithMany("SeriesEntries") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_books_book_id"); + + b.HasOne("PageManager.Api.Data.Models.Series", "Series") + .WithMany("Entries") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_series_entries_series_series_id"); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Author", b => + { + b.Navigation("BookAuthors"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Book", b => + { + b.Navigation("BookAuthors"); + + b.Navigation("SeriesEntries"); + }); + + modelBuilder.Entity("PageManager.Api.Data.Models.Series", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PageManager.Api/PageManager.Api/PageManager.Api.csproj b/PageManager.Api/PageManager.Api/PageManager.Api.csproj new file mode 100644 index 0000000..a0b221a --- /dev/null +++ b/PageManager.Api/PageManager.Api/PageManager.Api.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + Linux + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + .dockerignore + + + + diff --git a/PageManager.Api/PageManager.Api/PageManager.Api.http b/PageManager.Api/PageManager.Api/PageManager.Api.http new file mode 100644 index 0000000..0936ab4 --- /dev/null +++ b/PageManager.Api/PageManager.Api/PageManager.Api.http @@ -0,0 +1,11 @@ +@PageManager.Api_HostAddress = http://localhost:5278 + +GET {{PageManager.Api_HostAddress}}/api/books +Accept: application/json + +### + +GET {{PageManager.Api_HostAddress}}/api/books/1 +Accept: application/json + +### diff --git a/PageManager.Api/PageManager.Api/Program.cs b/PageManager.Api/PageManager.Api/Program.cs new file mode 100644 index 0000000..f5584aa --- /dev/null +++ b/PageManager.Api/PageManager.Api/Program.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Data; +using PageManager.Api.Data.Repositories; +using PageManager.Api.Services; +using Scalar.AspNetCore; +using Serilog; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Routing", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.StaticFiles", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning) + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") + .CreateBootstrapLogger(); + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseSerilog((ctx, services, cfg) => cfg + .ReadFrom.Configuration(ctx.Configuration) + .ReadFrom.Services(services) + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Routing", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.StaticFiles", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", Serilog.Events.LogEventLevel.Warning) + .MinimumLevel.Override("System.Net.Http.HttpClient", Serilog.Events.LogEventLevel.Warning) + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")); + +builder.Services.AddControllers(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddOpenApi(); + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"), + o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)) + .UseSnakeCaseNamingConvention()); + +builder.Services.AddCors(options => +{ + options.AddPolicy("DevCors", policy => + policy.WithOrigins("http://localhost:5173") + .AllowAnyHeader() + .AllowAnyMethod()); +}); + +var app = builder.Build(); + +app.UseSerilogRequestLogging(opts => +{ + opts.MessageTemplate = "{RequestMethod} {RequestPath} → {StatusCode} ({Elapsed:0.0}ms)"; +}); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); + app.UseCors("DevCors"); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); + +public partial class Program { } diff --git a/PageManager.Api/PageManager.Api/Properties/launchSettings.json b/PageManager.Api/PageManager.Api/Properties/launchSettings.json new file mode 100644 index 0000000..9e4882e --- /dev/null +++ b/PageManager.Api/PageManager.Api/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar/v1", + "applicationUrl": "http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar/v1", + "applicationUrl": "https://localhost:7196;http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/PageManager.Api/PageManager.Api/Services/BooksService.cs b/PageManager.Api/PageManager.Api/Services/BooksService.cs new file mode 100644 index 0000000..ff17ae8 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/BooksService.cs @@ -0,0 +1,103 @@ +using PageManager.Api.Api.Dtos; +using PageManager.Api.Data.Models; +using PageManager.Api.Data.Repositories; + +namespace PageManager.Api.Services; + +public class BooksService(IBooksRepository repo, IHardcoverService hardcover) : IBooksService +{ + public async Task> GetAllAsync() + { + var books = await repo.GetAllAsync(); + return books.Select(ToDto); + } + + public async Task GetByIdAsync(int id) + { + var book = await repo.GetByIdAsync(id); + return book is null ? null : ToDto(book); + } + + public async Task UpdateAsync(int id, UpdateBookRequest req) + { + var book = await repo.GetByIdAsync(id); + if (book is null) return null; + + book.Title = req.Title; + book.Year = req.Year; + book.Publisher = req.Publisher; + book.Pages = req.Pages; + book.Description = req.Description; + book.Formats = req.Formats; + book.Color = req.Color; + book.Genres = req.Genres; + book.CoverUrl = req.CoverUrl; + book.Isbn = req.Isbn; + book.HardcoverId = req.HardcoverId; + + await repo.SaveAsync(); + return ToDto(book); + } + + public async Task CreateFromHardcoverAsync(int hardcoverId) + { + // Idempotent: return existing book if already in DB + var existing = await repo.FindByHardcoverIdAsync(hardcoverId); + if (existing is not null) return ToDto(existing); + + var details = await hardcover.GetBookDetailsAsync(hardcoverId); + if (details is null) return null; + + var color = details.CoverColor is { Length: > 0 } c && c.StartsWith('#') ? c : "#6366f1"; + + var book = new Book + { + Title = details.Title, + Year = details.Year, + Publisher = details.Publisher, + Pages = details.Pages, + Description = details.Description, + Formats = [], + Color = color, + Genres = details.Genres, + CoverUrl = details.CoverUrl, + Isbn = details.Isbn, + HardcoverId = details.Id, + }; + + (string name, double position, string? arc)? seriesArg = details.Series is { } s + ? (s.Name, s.Position, (string?)null) : null; + + var created = await repo.CreateBookAsync(book, details.Authors, seriesArg); + return ToDto(created); + } + + private static BookDto ToDto(Book book) + { + var entry = book.SeriesEntries.FirstOrDefault(); + return new BookDto + { + Id = book.Id, + Title = book.Title, + Year = book.Year, + Publisher = book.Publisher, + Pages = book.Pages, + Description = book.Description, + Formats = book.Formats, + Color = book.Color, + Genres = book.Genres, + Authors = book.BookAuthors + .Select(ba => new AuthorDto { Id = ba.Author.Id, Name = ba.Author.Name }) + .ToArray(), + Series = entry is null ? null : new BookSeriesDto + { + Name = entry.Series.Name, + Position = entry.Position, + Arc = entry.Arc, + }, + CoverUrl = book.CoverUrl, + Isbn = book.Isbn, + HardcoverId = book.HardcoverId, + }; + } +} diff --git a/PageManager.Api/PageManager.Api/Services/HardcoverService.cs b/PageManager.Api/PageManager.Api/Services/HardcoverService.cs new file mode 100644 index 0000000..664ee39 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/HardcoverService.cs @@ -0,0 +1,287 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using PageManager.Api.Api.Dtos; + +namespace PageManager.Api.Services; + +public class HardcoverService(HttpClient http, IConfiguration config, ILogger logger) : IHardcoverService +{ + private const string Endpoint = "https://api.hardcover.app/v1/graphql"; + + private const string SearchQuery = """ + query SearchBooks($q: String!) { + search(query: $q, query_type: "Book", per_page: 15, page: 1) { + results + } + } + """; + + private const string DetailsQuery = """ + query GetBookDetails($id: Int!) { + books(where: {id: {_eq: $id}}, limit: 1) { + id + title + description + pages + release_year + cached_contributors + cached_tags + book_series(order_by: {position: asc}, limit: 1) { + position + series { id name } + } + editions(order_by: {users_count: desc}, limit: 3) { + isbn_13 + publisher { name } + image { url color } + } + } + } + """; + + public async Task> SearchBooksAsync(string query) + { + using var doc = await SendQueryAsync(SearchQuery, new { q = query }); + + if (!doc.RootElement.TryGetProperty("data", out var data) || + !data.TryGetProperty("search", out var search) || + !search.TryGetProperty("results", out var results)) + return []; + + // results is a Typesense JSON blob — may be embedded object or string + if (results.ValueKind == JsonValueKind.String) + { + using var inner = JsonDocument.Parse(results.GetString()!); + return inner.RootElement.TryGetProperty("hits", out var h) ? ParseHits(h) : []; + } + + return results.TryGetProperty("hits", out var hits) ? ParseHits(hits) : []; + } + + public async Task GetBookDetailsAsync(int hardcoverId) + { + logger.LogInformation("Fetching Hardcover book details for id={Id}", hardcoverId); + + using var doc = await SendQueryAsync(DetailsQuery, new { id = hardcoverId }); + + if (!doc.RootElement.TryGetProperty("data", out var data)) + { + logger.LogWarning("Hardcover response missing 'data' for id={Id}. Response: {Json}", + hardcoverId, doc.RootElement.GetRawText()); + return null; + } + + if (!data.TryGetProperty("books", out var books)) + { + logger.LogWarning("Hardcover response missing 'books' for id={Id}. Data: {Json}", + hardcoverId, data.GetRawText()); + return null; + } + + if (books.GetArrayLength() == 0) + { + logger.LogWarning("Hardcover returned empty books array for id={Id}", hardcoverId); + return null; + } + + var details = ParseBookDetails(books[0]); + if (details.Authors.Length == 0) + logger.LogWarning("No authors parsed for book id={Id}. cached_contributors: {Raw}", + hardcoverId, + books[0].TryGetProperty("cached_contributors", out var cc) ? cc.GetRawText() : "missing"); + + return details; + } + + // ── Parsing ─────────────────────────────────────────────────────────────── + + private static List ParseHits(JsonElement hitsEl) + { + var list = new List(); + foreach (var hit in hitsEl.EnumerateArray()) + { + if (!hit.TryGetProperty("document", out var d)) continue; + + var id = 0; + if (d.TryGetProperty("id", out var idEl)) + id = idEl.ValueKind == JsonValueKind.Number + ? idEl.GetInt32() + : int.TryParse(idEl.GetString(), out var p) ? p : 0; + if (id == 0) continue; + + var title = d.TryGetProperty("title", out var tEl) ? tEl.GetString() ?? "" : ""; + + var authors = new List(); + if (d.TryGetProperty("author_names", out var aNamesEl)) + foreach (var a in aNamesEl.EnumerateArray()) + if (a.GetString() is string n) authors.Add(n); + + int? year = d.TryGetProperty("release_year", out var yEl) && yEl.ValueKind == JsonValueKind.Number + ? yEl.GetInt32() : null; + + var genres = new List(); + if (d.TryGetProperty("genres", out var gEl)) + foreach (var g in gEl.EnumerateArray()) + if (g.GetString() is string gn) genres.Add(gn); + + list.Add(new HardcoverBookResult + { + Id = id, + Title = title, + Authors = [.. authors], + Year = year, + Genres = [.. genres], + }); + } + return list; + } + + private static HardcoverBookDetails ParseBookDetails(JsonElement book) + { + var id = book.TryGetProperty("id", out var idEl) ? idEl.GetInt32() : 0; + var title = book.TryGetProperty("title", out var tEl) ? tEl.GetString() ?? "" : ""; + var desc = book.TryGetProperty("description", out var dEl) && dEl.ValueKind != JsonValueKind.Null ? dEl.GetString() : null; + int? pages = book.TryGetProperty("pages", out var pEl) && pEl.ValueKind == JsonValueKind.Number ? pEl.GetInt32() : null; + int? year = book.TryGetProperty("release_year",out var yEl) && yEl.ValueKind == JsonValueKind.Number ? yEl.GetInt32() : null; + + var authors = ParseCachedContributors(book); + var genres = ParseCachedTags(book); + var series = ParseBookSeries(book); + var (isbn, publisher, coverUrl, coverColor) = ParseEditions(book); + + return new HardcoverBookDetails + { + Id = id, + Title = title, + Description = desc, + Pages = pages, + Year = year, + Authors = [.. authors], + Genres = [.. genres], + Isbn = isbn, + Publisher = publisher, + CoverUrl = coverUrl, + CoverColor = coverColor, + Series = series, + }; + } + + private static List ParseCachedContributors(JsonElement book) + { + var authors = new List(); + if (!book.TryGetProperty("cached_contributors", out var el)) return authors; + + if (el.ValueKind == JsonValueKind.String) + { + if (el.GetString() is not string s) return authors; + using var inner = JsonDocument.Parse(s); + ExtractContributorNames(inner.RootElement, authors); + return authors; + } + + ExtractContributorNames(el, authors); + return authors; + } + + private static void ExtractContributorNames(JsonElement el, List authors) + { + if (el.ValueKind == JsonValueKind.Array) + { + foreach (var c in el.EnumerateArray()) + if (c.TryGetProperty("name", out var n) && n.GetString() is string name) + authors.Add(name); + } + else if (el.ValueKind == JsonValueKind.Object) + { + // Format: { "Author": [{name, ...}], "Narrator": [...] } + foreach (var role in el.EnumerateObject()) + ExtractContributorNames(role.Value, authors); + } + } + + private static List ParseCachedTags(JsonElement book) + { + var tags = new List(); + if (!book.TryGetProperty("cached_tags", out var el)) return tags; + + if (el.ValueKind == JsonValueKind.String) + { + if (el.GetString() is not string s) return tags; + using var inner = JsonDocument.Parse(s); + ExtractTagNames(inner.RootElement, tags); + return tags; + } + + ExtractTagNames(el, tags); + return tags; + } + + private static void ExtractTagNames(JsonElement el, List tags) + { + if (el.ValueKind != JsonValueKind.Array) return; + foreach (var t in el.EnumerateArray()) + if (t.TryGetProperty("tag", out var n) && n.GetString() is string tag) + tags.Add(tag); + } + + private static HardcoverSeriesInfo? ParseBookSeries(JsonElement book) + { + if (!book.TryGetProperty("book_series", out var arr) || arr.GetArrayLength() == 0) + return null; + + var entry = arr[0]; + var position = entry.TryGetProperty("position", out var posEl) && posEl.ValueKind == JsonValueKind.Number + ? posEl.GetDouble() : 1.0; + string? seriesName = null; + if (entry.TryGetProperty("series", out var seriesEl) && seriesEl.ValueKind != JsonValueKind.Null) + seriesName = seriesEl.TryGetProperty("name", out var snEl) ? snEl.GetString() : null; + + return seriesName is null ? null : new HardcoverSeriesInfo { Name = seriesName, Position = position }; + } + + private static (string? isbn, string? publisher, string? coverUrl, string? coverColor) ParseEditions(JsonElement book) + { + string? isbn = null, publisher = null, coverUrl = null, coverColor = null; + + if (!book.TryGetProperty("editions", out var editions)) return (isbn, publisher, coverUrl, coverColor); + + foreach (var ed in editions.EnumerateArray()) + { + if (isbn is null && ed.TryGetProperty("isbn_13", out var isbnEl) && isbnEl.ValueKind != JsonValueKind.Null) + isbn = isbnEl.GetString(); + + if (publisher is null && ed.TryGetProperty("publisher", out var pubEl) && pubEl.ValueKind != JsonValueKind.Null) + if (pubEl.TryGetProperty("name", out var pnEl)) publisher = pnEl.GetString(); + + if (coverUrl is null && ed.TryGetProperty("image", out var imgEl) && imgEl.ValueKind != JsonValueKind.Null) + { + if (imgEl.TryGetProperty("url", out var urlEl)) coverUrl = urlEl.GetString(); + if (imgEl.TryGetProperty("color", out var colorEl)) coverColor = colorEl.GetString(); + } + + if (isbn is not null && publisher is not null && coverUrl is not null) break; + } + + return (isbn, publisher, coverUrl, coverColor); + } + + // ── HTTP ────────────────────────────────────────────────────────────────── + + private async Task SendQueryAsync(string query, object? variables = null) + { + var request = new HttpRequestMessage(HttpMethod.Post, Endpoint) + { + Content = JsonContent.Create(new { query, variables }) + }; + + var apiKey = config["Hardcover:ApiKey"]; + if (!string.IsNullOrEmpty(apiKey)) + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + + var response = await http.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var stream = await response.Content.ReadAsStreamAsync(); + return await JsonDocument.ParseAsync(stream); + } +} diff --git a/PageManager.Api/PageManager.Api/Services/IBooksService.cs b/PageManager.Api/PageManager.Api/Services/IBooksService.cs new file mode 100644 index 0000000..e485674 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IBooksService.cs @@ -0,0 +1,16 @@ +using PageManager.Api.Api.Dtos; + +namespace PageManager.Api.Services; + +public interface IBooksService +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task UpdateAsync(int id, UpdateBookRequest req); + /// + /// Fetches full metadata from Hardcover and persists the book. + /// Returns the existing book if hardcoverId already exists in the DB. + /// Returns null if the Hardcover API does not recognise the id. + /// + Task CreateFromHardcoverAsync(int hardcoverId); +} diff --git a/PageManager.Api/PageManager.Api/Services/IHardcoverService.cs b/PageManager.Api/PageManager.Api/Services/IHardcoverService.cs new file mode 100644 index 0000000..dfd094b --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IHardcoverService.cs @@ -0,0 +1,9 @@ +using PageManager.Api.Api.Dtos; + +namespace PageManager.Api.Services; + +public interface IHardcoverService +{ + Task> SearchBooksAsync(string query); + Task GetBookDetailsAsync(int hardcoverId); +} diff --git a/PageManager.Api/PageManager.Api/Services/IImportService.cs b/PageManager.Api/PageManager.Api/Services/IImportService.cs new file mode 100644 index 0000000..d2bdc99 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/IImportService.cs @@ -0,0 +1,12 @@ +using PageManager.Api.Api.Dtos; + +namespace PageManager.Api.Services; + +public interface IImportService +{ + Task> GetSourcesAsync(); + Task UpdateSourceAsync(string id, bool enabled); + Task> GetQueueAsync(); + Task RemoveQueueItemAsync(string id); + Task RetryQueueItemAsync(string id); +} diff --git a/PageManager.Api/PageManager.Api/Services/ImportService.cs b/PageManager.Api/PageManager.Api/Services/ImportService.cs new file mode 100644 index 0000000..5b087e7 --- /dev/null +++ b/PageManager.Api/PageManager.Api/Services/ImportService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using PageManager.Api.Api.Dtos; +using PageManager.Api.Data; +using PageManager.Api.Data.Models; + +namespace PageManager.Api.Services; + +public class ImportService(AppDbContext db) : IImportService +{ + public async Task> GetSourcesAsync() => + (await db.ImportSources.ToListAsync()).Select(ToDto); + + public async Task UpdateSourceAsync(string id, bool enabled) + { + var source = await db.ImportSources.FindAsync(id); + if (source is null) return null; + + source.Enabled = enabled; + await db.SaveChangesAsync(); + return ToDto(source); + } + + public async Task> GetQueueAsync() => + (await db.ImportQueueItems + .OrderByDescending(i => i.Status == QueueItemStatus.Downloading) + .ThenByDescending(i => i.Status == QueueItemStatus.Queued) + .ToListAsync()) + .Select(ToDto); + + public async Task RemoveQueueItemAsync(string id) + { + var item = await db.ImportQueueItems.FindAsync(id); + if (item is null) return false; + + db.ImportQueueItems.Remove(item); + await db.SaveChangesAsync(); + return true; + } + + public async Task RetryQueueItemAsync(string id) + { + var item = await db.ImportQueueItems.FindAsync(id); + if (item is null || item.Status != QueueItemStatus.Failed) return false; + + item.Status = QueueItemStatus.Queued; + item.Error = null; + await db.SaveChangesAsync(); + return true; + } + + private static ImportSourceDto ToDto(ImportSource s) => new() + { + Id = s.Id, + Name = s.Name, + Type = s.Type.ToString().ToLowerInvariant(), + Path = s.Path, + Enabled = s.Enabled, + }; + + private static QueueItemDto ToDto(ImportQueueItem i) => new() + { + Id = i.Id, + Filename = i.Filename, + SizeBytes = i.SizeBytes, + DownloadedBytes = i.DownloadedBytes, + Status = i.Status.ToString().ToLowerInvariant(), + Source = i.Source, + Error = i.Error, + }; +} diff --git a/PageManager.Api/PageManager.Api/appsettings.json b/PageManager.Api/PageManager.Api/appsettings.json new file mode 100644 index 0000000..0ddde1b --- /dev/null +++ b/PageManager.Api/PageManager.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Hardcover": { + "ApiKey": "" + } +} diff --git a/PageManager.Api/compose.yaml b/PageManager.Api/compose.yaml new file mode 100644 index 0000000..99e25e2 --- /dev/null +++ b/PageManager.Api/compose.yaml @@ -0,0 +1,7 @@ +services: + pagemanager.api: + image: pagemanager.api + build: + context: . + dockerfile: PageManager.Api/Dockerfile + diff --git a/PageManager.Web/.env b/PageManager.Web/.env new file mode 100644 index 0000000..a73346e --- /dev/null +++ b/PageManager.Web/.env @@ -0,0 +1,2 @@ +# In development, leave VITE_API_URL unset — Vite proxies /api to http://localhost:5278 +# VITE_API_URL=http://localhost:5278/api \ No newline at end of file diff --git a/PageManager.Web/.gitignore b/PageManager.Web/.gitignore new file mode 100644 index 0000000..b2c6633 --- /dev/null +++ b/PageManager.Web/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.local +.env.*.local +coverage/ diff --git a/PageManager.Web/Dockerfile b/PageManager.Web/Dockerfile new file mode 100644 index 0000000..41c9dce --- /dev/null +++ b/PageManager.Web/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine AS final +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/PageManager.Web/index.html b/PageManager.Web/index.html new file mode 100644 index 0000000..dd63787 --- /dev/null +++ b/PageManager.Web/index.html @@ -0,0 +1,16 @@ + + + + + + PageManager + + + + + + +
+ + + diff --git a/PageManager.Web/nginx.conf b/PageManager.Web/nginx.conf new file mode 100644 index 0000000..719ed00 --- /dev/null +++ b/PageManager.Web/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 80; + + root /usr/share/nginx/html; + index index.html; + + # Proxy /api to the backend + location /api/ { + proxy_pass http://pagemanager.api:8080/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # SPA fallback — all other routes serve index.html + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/PageManager.Web/package-lock.json b/PageManager.Web/package-lock.json new file mode 100644 index 0000000..4a6cfb0 --- /dev/null +++ b/PageManager.Web/package-lock.json @@ -0,0 +1,3844 @@ +{ + "name": "pagemanager-web", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pagemanager-web", + "version": "0.0.1", + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.1.1", + "jsdom": "^26.1.0", + "typescript": "~5.7.2", + "vite": "^6.0.5", + "vitest": "^3.1.1" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "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/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/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/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@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.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@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/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001779", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", + "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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/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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "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/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "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/glob/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/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "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/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/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/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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "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/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-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": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "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/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/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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/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/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.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-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "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/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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-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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "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/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "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/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/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/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.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "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/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "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/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "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": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "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/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-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/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/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-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "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" + } + } +} diff --git a/PageManager.Web/package.json b/PageManager.Web/package.json new file mode 100644 index 0000000..017de52 --- /dev/null +++ b/PageManager.Web/package.json @@ -0,0 +1,32 @@ +{ + "name": "pagemanager-web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/coverage-v8": "^3.1.1", + "jsdom": "^26.1.0", + "typescript": "~5.7.2", + "vite": "^6.0.5", + "vitest": "^3.1.1" + } +} diff --git a/PageManager.Web/src/App.module.css b/PageManager.Web/src/App.module.css new file mode 100644 index 0000000..18985b7 --- /dev/null +++ b/PageManager.Web/src/App.module.css @@ -0,0 +1,20 @@ +.shell { + display: flex; + height: 100%; + overflow: hidden; + background: var(--md-sys-color-background); +} + +.content { + flex: 1; + min-width: 0; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--md-sys-color-surface); +} + +.content > * { + flex: 1; + min-height: 0; +} diff --git a/PageManager.Web/src/App.tsx b/PageManager.Web/src/App.tsx new file mode 100644 index 0000000..2b0cd3c --- /dev/null +++ b/PageManager.Web/src/App.tsx @@ -0,0 +1,22 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import Sidebar from './components/Sidebar/Sidebar' +import Library from './pages/Library/Library' +import Import from './pages/Import/Import' +import Metadata from './pages/Metadata/Metadata' +import s from './App.module.css' + +export default function App() { + return ( +
+ +
+ + } /> + } /> + } /> + } /> + +
+
+ ) +} diff --git a/PageManager.Web/src/api/__tests__/books.test.ts b/PageManager.Web/src/api/__tests__/books.test.ts new file mode 100644 index 0000000..fd1d646 --- /dev/null +++ b/PageManager.Web/src/api/__tests__/books.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fetchBooks, fetchBook, updateBook } from '../books' + +const mockBook = { + id: 1, + title: 'Dune', + year: 1965, + publisher: null, + pages: null, + description: null, + formats: ['epub'] as const, + color: '#6366f1', + genres: ['Science Fiction'], + authors: [{ id: 1, name: 'Frank Herbert' }], + coverUrl: null, + isbn: null, + hardcoverId: null, +} + +function mockFetch(body: unknown, ok = true, status = 200) { + return vi.fn().mockResolvedValue({ + ok, + status, + statusText: ok ? 'OK' : 'Not Found', + json: () => Promise.resolve(body), + }) +} + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch([mockBook])) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('fetchBooks', () => { + it('calls GET /api/books', async () => { + await fetchBooks() + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/books'), + expect.objectContaining({ headers: expect.any(Object) }), + ) + }) + + it('returns the parsed array', async () => { + const result = await fetchBooks() + expect(result).toEqual([mockBook]) + }) + + it('throws on non-ok response', async () => { + vi.stubGlobal('fetch', mockFetch(null, false, 500)) + await expect(fetchBooks()).rejects.toThrow('500') + }) +}) + +describe('fetchBook', () => { + it('calls GET /api/books/:id', async () => { + vi.stubGlobal('fetch', mockFetch(mockBook)) + await fetchBook(42) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/books/42'), + expect.any(Object), + ) + }) + + it('throws on non-ok response', async () => { + vi.stubGlobal('fetch', mockFetch(null, false, 404)) + await expect(fetchBook(99)).rejects.toThrow('404') + }) +}) + +describe('updateBook', () => { + it('calls PUT /api/books/:id with JSON body', async () => { + vi.stubGlobal('fetch', mockFetch(mockBook)) + const patch = { title: 'New Title' } + await updateBook(1, patch) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/books/1'), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(patch), + }), + ) + }) + + it('returns the updated book', async () => { + vi.stubGlobal('fetch', mockFetch(mockBook)) + const result = await updateBook(1, { title: 'New Title' }) + expect(result).toEqual(mockBook) + }) + + it('throws on non-ok response', async () => { + vi.stubGlobal('fetch', mockFetch(null, false, 404)) + await expect(updateBook(99, {})).rejects.toThrow('404') + }) +}) diff --git a/PageManager.Web/src/api/__tests__/importQueue.test.ts b/PageManager.Web/src/api/__tests__/importQueue.test.ts new file mode 100644 index 0000000..8d23cd3 --- /dev/null +++ b/PageManager.Web/src/api/__tests__/importQueue.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { fetchQueue, fetchSources, retryQueueItem, removeQueueItem, updateSource } from '../importQueue' +import type { QueueItem, ImportSource } from '../../types' + +const mockQueue: QueueItem[] = [ + { id: 'q1', filename: 'book.epub', sizeBytes: 1024, downloadedBytes: 512, status: 'downloading', source: 'https://example.com' }, +] + +const mockSources: ImportSource[] = [ + { id: 's1', name: 'My Library', type: 'folder', path: '/books', enabled: true }, +] + +function mockFetch(body: unknown, ok = true, status = 200) { + return vi.fn().mockResolvedValue({ + ok, + status, + statusText: ok ? 'OK' : 'Not Found', + json: () => Promise.resolve(body), + }) +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('fetchQueue', () => { + it('calls GET /api/queue', async () => { + vi.stubGlobal('fetch', mockFetch(mockQueue)) + await fetchQueue() + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/queue'), expect.any(Object)) + }) + + it('returns queue items', async () => { + vi.stubGlobal('fetch', mockFetch(mockQueue)) + expect(await fetchQueue()).toEqual(mockQueue) + }) +}) + +describe('fetchSources', () => { + it('calls GET /api/sources', async () => { + vi.stubGlobal('fetch', mockFetch(mockSources)) + await fetchSources() + expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/sources'), expect.any(Object)) + }) + + it('returns sources', async () => { + vi.stubGlobal('fetch', mockFetch(mockSources)) + expect(await fetchSources()).toEqual(mockSources) + }) +}) + +describe('retryQueueItem', () => { + it('calls POST /api/queue/:id/retry', async () => { + vi.stubGlobal('fetch', mockFetch(null)) + await retryQueueItem('q1') + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/queue/q1/retry'), + expect.objectContaining({ method: 'POST' }), + ) + }) +}) + +describe('removeQueueItem', () => { + it('calls DELETE /api/queue/:id', async () => { + vi.stubGlobal('fetch', mockFetch(null)) + await removeQueueItem('q1') + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/queue/q1'), + expect.objectContaining({ method: 'DELETE' }), + ) + }) +}) + +describe('updateSource', () => { + it('calls PATCH /api/sources/:id with enabled flag', async () => { + vi.stubGlobal('fetch', mockFetch({ ...mockSources[0], enabled: false })) + await updateSource('s1', false) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/sources/s1'), + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ enabled: false }), + }), + ) + }) +}) diff --git a/PageManager.Web/src/api/__tests__/search.test.ts b/PageManager.Web/src/api/__tests__/search.test.ts new file mode 100644 index 0000000..a58e6ff --- /dev/null +++ b/PageManager.Web/src/api/__tests__/search.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { searchHardcover, addBookFromHardcover } from '../search' +import type { HardcoverSearchResult, Book } from '../../types' + +const mockResults: HardcoverSearchResult[] = [ + { id: 1, title: 'Dune', authors: ['Frank Herbert'], year: 1965, genres: ['Science Fiction'] }, +] + +const mockBook: Book = { + id: 10, + title: 'Dune', + year: 1965, + publisher: 'Ace Books', + pages: 412, + description: null, + formats: [], + color: '#6366f1', + genres: ['Science Fiction'], + authors: [{ id: 1, name: 'Frank Herbert' }], + coverUrl: null, + isbn: null, + hardcoverId: 1, +} + +function mockFetch(body: unknown, ok = true, status = 200) { + return vi.fn().mockResolvedValue({ + ok, + status, + statusText: ok ? 'OK' : 'Not Found', + json: () => Promise.resolve(body), + }) +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('searchHardcover', () => { + it('calls GET /api/search/books with encoded query', async () => { + vi.stubGlobal('fetch', mockFetch(mockResults)) + await searchHardcover('dune frank') + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/search/books?q=dune%20frank'), + expect.any(Object), + ) + }) + + it('returns parsed results array', async () => { + vi.stubGlobal('fetch', mockFetch(mockResults)) + const result = await searchHardcover('dune') + expect(result).toEqual(mockResults) + }) + + it('throws on non-ok response', async () => { + vi.stubGlobal('fetch', mockFetch(null, false, 500)) + await expect(searchHardcover('dune')).rejects.toThrow('500') + }) +}) + +describe('addBookFromHardcover', () => { + it('calls POST /api/books with hardcoverId in body', async () => { + vi.stubGlobal('fetch', mockFetch(mockBook)) + await addBookFromHardcover(42) + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/books'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ hardcoverId: 42 }), + }), + ) + }) + + it('returns the created book', async () => { + vi.stubGlobal('fetch', mockFetch(mockBook)) + const result = await addBookFromHardcover(1) + expect(result).toEqual(mockBook) + }) + + it('throws on non-ok response', async () => { + vi.stubGlobal('fetch', mockFetch(null, false, 404)) + await expect(addBookFromHardcover(999)).rejects.toThrow('404') + }) +}) diff --git a/PageManager.Web/src/api/books.ts b/PageManager.Web/src/api/books.ts new file mode 100644 index 0000000..77635b7 --- /dev/null +++ b/PageManager.Web/src/api/books.ts @@ -0,0 +1,14 @@ +import type { Book } from '../types' +import { api } from './client' + +export function fetchBooks(): Promise { + return api.get('/books') +} + +export function fetchBook(id: number): Promise { + return api.get(`/books/${id}`) +} + +export function updateBook(id: number, patch: Partial): Promise { + return api.put(`/books/${id}`, patch) +} diff --git a/PageManager.Web/src/api/client.ts b/PageManager.Web/src/api/client.ts new file mode 100644 index 0000000..ff33c5d --- /dev/null +++ b/PageManager.Web/src/api/client.ts @@ -0,0 +1,21 @@ +const BASE_URL = import.meta.env.VITE_API_URL ?? '/api' + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, { + headers: { 'Content-Type': 'application/json', ...init?.headers }, + ...init, + }) + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) + return res.json() as Promise +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: 'POST', body: JSON.stringify(body) }), + put: (path: string, body: unknown) => + request(path, { method: 'PUT', body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: 'DELETE' }), +} diff --git a/PageManager.Web/src/api/importQueue.ts b/PageManager.Web/src/api/importQueue.ts new file mode 100644 index 0000000..2139df7 --- /dev/null +++ b/PageManager.Web/src/api/importQueue.ts @@ -0,0 +1,22 @@ +import type { QueueItem, ImportSource } from '../types' +import { api } from './client' + +export function fetchQueue(): Promise { + return api.get('/queue') +} + +export function fetchSources(): Promise { + return api.get('/sources') +} + +export function retryQueueItem(id: string): Promise { + return api.post(`/queue/${id}/retry`, {}) +} + +export function removeQueueItem(id: string): Promise { + return api.delete(`/queue/${id}`) +} + +export function updateSource(id: string, enabled: boolean): Promise { + return api.patch(`/sources/${id}`, { enabled }) +} diff --git a/PageManager.Web/src/api/search.ts b/PageManager.Web/src/api/search.ts new file mode 100644 index 0000000..4cf438d --- /dev/null +++ b/PageManager.Web/src/api/search.ts @@ -0,0 +1,10 @@ +import type { Book, HardcoverSearchResult } from '../types' +import { api } from './client' + +export function searchHardcover(q: string): Promise { + return api.get(`/search/books?q=${encodeURIComponent(q)}`) +} + +export function addBookFromHardcover(hardcoverId: number): Promise { + return api.post('/books', { hardcoverId }) +} diff --git a/PageManager.Web/src/components/AddBookDialog/AddBookDialog.module.css b/PageManager.Web/src/components/AddBookDialog/AddBookDialog.module.css new file mode 100644 index 0000000..0c553e9 --- /dev/null +++ b/PageManager.Web/src/components/AddBookDialog/AddBookDialog.module.css @@ -0,0 +1,194 @@ +/* MD3 Full-screen scrim */ +.scrim { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .5); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 150ms ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* MD3 Dialog */ +.dialog { + width: 100%; + max-width: 560px; + max-height: 80vh; + background: var(--md-sys-color-surface-container-high); + border-radius: var(--md-sys-shape-xl); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideUp 200ms cubic-bezier(.3,0,0,1); +} + +@keyframes slideUp { + from { transform: translateY(24px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.header { + display: flex; + align-items: center; + gap: 8px; + padding: 20px 24px 0; + flex-shrink: 0; +} + +.heading { + font: var(--md-sys-typescale-headline-small); + color: var(--md-sys-color-on-surface); + flex: 1; +} + +/* MD3 Icon Button */ +.closeBtn { + width: 40px; + height: 40px; + border-radius: var(--md-sys-shape-full); + display: flex; + align-items: center; + justify-content: center; + color: var(--md-sys-color-on-surface-variant); + position: relative; + overflow: hidden; +} +.closeBtn::before { + content: ''; + position: absolute; + inset: 0; + background: currentColor; + opacity: 0; + transition: opacity 200ms; +} +.closeBtn:hover::before { opacity: .08; } +.closeBtn:active::before { opacity: .12; } + +/* Search field */ +.searchWrap { + padding: 16px 24px 12px; + flex-shrink: 0; +} + +.search { + display: flex; + align-items: center; + gap: 12px; + height: 52px; + padding: 0 16px; + background: var(--md-sys-color-surface-container-highest); + border-radius: var(--md-sys-shape-full); +} + +.searchIcon { + color: var(--md-sys-color-on-surface-variant); + flex-shrink: 0; +} + +.searchInput { + flex: 1; + border: none; + background: transparent; + font: var(--md-sys-typescale-body-large); + color: var(--md-sys-color-on-surface); +} + +.searchInput::placeholder { + color: var(--md-sys-color-on-surface-variant); +} + +/* Results list */ +.results { + flex: 1; + overflow-y: auto; + padding: 0 8px 16px; +} + +.empty { + padding: 32px 16px; + text-align: center; + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-body-large); +} + +/* Spinner */ +.spinner { + padding: 32px; + display: flex; + justify-content: center; + color: var(--md-sys-color-primary); +} + +/* MD3 List Item */ +.row { + display: flex; + align-items: center; + padding: 8px 16px; + border-radius: var(--md-sys-shape-md); + gap: 12px; + cursor: pointer; + position: relative; + overflow: hidden; +} + +.row::before { + content: ''; + position: absolute; + inset: 0; + background: var(--md-sys-color-on-surface); + opacity: 0; + transition: opacity 200ms; +} +.row:hover::before { opacity: .08; } +.row:active::before { opacity: .12; } + +.rowAdded { + cursor: default; +} + +.rowAdded::before { background: var(--md-sys-color-primary); } +.rowAdded:hover::before { opacity: .05; } + +.rowContent { + flex: 1; + min-width: 0; +} + +.rowTitle { + font: var(--md-sys-typescale-body-large); + color: var(--md-sys-color-on-surface); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rowMeta { + font: var(--md-sys-typescale-body-small); + color: var(--md-sys-color-on-surface-variant); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.rowAction { + flex-shrink: 0; + color: var(--md-sys-color-on-surface-variant); + font-size: 20px !important; + transition: color 200ms; +} + +.rowAdded .rowAction { + color: var(--md-sys-color-primary); +} + +.rowLoading { + cursor: default; + pointer-events: none; +} diff --git a/PageManager.Web/src/components/AddBookDialog/AddBookDialog.tsx b/PageManager.Web/src/components/AddBookDialog/AddBookDialog.tsx new file mode 100644 index 0000000..9c1ccf9 --- /dev/null +++ b/PageManager.Web/src/components/AddBookDialog/AddBookDialog.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react' +import type { Book, HardcoverSearchResult } from '../../types' +import { searchHardcover, addBookFromHardcover } from '../../api/search' +import s from './AddBookDialog.module.css' + +interface Props { + onClose: () => void + onAdded: (book: Book) => void +} + +export default function AddBookDialog({ onClose, onAdded }: Props) { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [adding, setAdding] = useState(null) + const [added, setAdded] = useState>(new Set()) + const inputRef = useRef(null) + + useEffect(() => { inputRef.current?.focus() }, []) + + // Debounced search + useEffect(() => { + if (!query.trim()) { setResults([]); return } + const timer = setTimeout(() => { + setLoading(true) + searchHardcover(query) + .then(setResults) + .catch(() => setResults([])) + .finally(() => setLoading(false)) + }, 400) + return () => clearTimeout(timer) + }, [query]) + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [onClose]) + + async function handleAdd(result: HardcoverSearchResult) { + if (adding !== null || added.has(result.id)) return + setAdding(result.id) + try { + const book = await addBookFromHardcover(result.id) + setAdded(prev => new Set(prev).add(result.id)) + onAdded(book) + } finally { + setAdding(null) + } + } + + const showEmpty = !loading && query.trim() && results.length === 0 + const showHint = !loading && !query.trim() + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+ Add book + +
+ +
+
+ search + setQuery(e.target.value)} + /> +
+
+ +
+ {loading && ( +
+ progress_activity +
+ )} + + {showHint && ( +

Start typing to search Hardcover

+ )} + + {showEmpty && ( +

No results for "{query}"

+ )} + + {!loading && results.map(r => { + const isAdded = added.has(r.id) + const isAdding = adding === r.id + + return ( +
handleAdd(r)} + role="button" + tabIndex={0} + onKeyDown={e => { if (e.key === 'Enter') handleAdd(r) }} + aria-label={`Add ${r.title}`} + > +
+
{r.title}
+
+ {r.authors.join(', ')} + {r.year ? ` · ${r.year}` : ''} +
+
+ + + {isAdding ? 'progress_activity' : isAdded ? 'check_circle' : 'add'} + +
+ ) + })} +
+
+
+ ) +} diff --git a/PageManager.Web/src/components/BookCard/BookCard.module.css b/PageManager.Web/src/components/BookCard/BookCard.module.css new file mode 100644 index 0000000..12d97d4 --- /dev/null +++ b/PageManager.Web/src/components/BookCard/BookCard.module.css @@ -0,0 +1,119 @@ +/* MD3 Elevated Card */ +.card { + display: flex; + flex-direction: column; + background: var(--md-sys-color-surface-container-low); + border-radius: var(--md-sys-shape-md); + box-shadow: var(--md-sys-elevation-1); + overflow: hidden; + cursor: pointer; + position: relative; + transition: box-shadow 200ms cubic-bezier(.2,0,0,1); + outline: none; +} + +.card:hover { + box-shadow: var(--md-sys-elevation-2); +} + +.card.selected { + box-shadow: var(--md-sys-elevation-2); + outline: 2px solid var(--md-sys-color-primary); + outline-offset: -2px; +} + +/* State layer for hover/press */ +.stateLayer { + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: var(--md-sys-color-on-surface); + opacity: 0; + transition: opacity 200ms; +} + +.card:hover .stateLayer { opacity: .08; } +.card:active .stateLayer { opacity: .12; } +.card.selected .stateLayer { opacity: .08; background: var(--md-sys-color-primary); } + +.cover { + aspect-ratio: 2/3; + position: relative; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.coverImg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.initials { + font-size: 1.75rem; + font-weight: 500; + color: rgba(255,255,255,.3); + letter-spacing: .04em; + user-select: none; +} + +.seriesPill { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0,0,0,.45); + color: #fff; + font: var(--md-sys-typescale-label-small); + padding: 2px 6px; + border-radius: var(--md-sys-shape-xs); +} + +.body { + padding: 8px 10px 10px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.title { + font: var(--md-sys-typescale-title-small); + color: var(--md-sys-color-on-surface); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.3; +} + +.author { + font: var(--md-sys-typescale-body-small); + color: var(--md-sys-color-on-surface-variant); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +/* MD3 Assist Chip */ +.chip { + height: 24px; + padding: 0 8px; + border-radius: var(--md-sys-shape-sm); + border: 1px solid var(--md-sys-color-outline-variant); + font: var(--md-sys-typescale-label-small); + color: var(--md-sys-color-on-surface-variant); + display: flex; + align-items: center; + background: transparent; +} diff --git a/PageManager.Web/src/components/BookCard/BookCard.tsx b/PageManager.Web/src/components/BookCard/BookCard.tsx new file mode 100644 index 0000000..5a69f09 --- /dev/null +++ b/PageManager.Web/src/components/BookCard/BookCard.tsx @@ -0,0 +1,47 @@ +import type { Book } from '../../types' +import s from './BookCard.module.css' + +interface Props { + book: Book + onClick: (book: Book) => void + selected?: boolean +} + +export default function BookCard({ book, onClick, selected }: Props) { + const initials = book.title + .split(' ') + .slice(0, 2) + .map(w => w[0]) + .join('') + .toUpperCase() + + return ( +
onClick(book)} + > +
+ {book.coverUrl + ? + : {initials} + } + {book.series && ( + #{book.series.position} + )} +
+ +
+

{book.title}

+

{book.authors.map(a => a.name).join(', ')}

+
+ {book.formats.map(f => ( + {f.toUpperCase()} + ))} +
+
+ + {/* MD3 state layer */} +
+
+ ) +} diff --git a/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx b/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx new file mode 100644 index 0000000..92cdff1 --- /dev/null +++ b/PageManager.Web/src/components/BookCard/__tests__/BookCard.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import BookCard from '../BookCard' +import type { Book } from '../../../types' + +function makeBook(overrides: Partial = {}): Book { + return { + id: 1, + title: 'Dune', + year: 1965, + publisher: null, + pages: null, + description: null, + formats: ['epub'], + color: '#6366f1', + genres: ['Science Fiction'], + authors: [{ id: 1, name: 'Frank Herbert' }], + coverUrl: null, + isbn: null, + hardcoverId: null, + ...overrides, + } +} + +describe('BookCard', () => { + it('renders the title', () => { + render() + expect(screen.getByText('Dune')).toBeInTheDocument() + }) + + it('renders the author name', () => { + render() + expect(screen.getByText('Frank Herbert')).toBeInTheDocument() + }) + + it('renders format chips', () => { + render() + expect(screen.getByText('EPUB')).toBeInTheDocument() + expect(screen.getByText('MOBI')).toBeInTheDocument() + }) + + it('renders series position pill when series exists', () => { + render() + expect(screen.getByText('#1')).toBeInTheDocument() + }) + + it('does not render series position pill when series is absent', () => { + render() + expect(screen.queryByText(/#\d/)).not.toBeInTheDocument() + }) + + it('calls onClick with the book when clicked', async () => { + const user = userEvent.setup() + const book = makeBook() + const onClick = vi.fn() + render() + await user.click(screen.getByRole('article')) + expect(onClick).toHaveBeenCalledWith(book) + }) + + it('applies selected styling when selected is true', () => { + const { container } = render() + const article = container.querySelector('article') + expect(article?.className).toContain('selected') + }) + + it('does not apply selected styling when selected is false', () => { + const { container } = render() + const article = container.querySelector('article') + expect(article?.className).not.toContain('selected') + }) +}) diff --git a/PageManager.Web/src/components/DetailPanel/DetailPanel.module.css b/PageManager.Web/src/components/DetailPanel/DetailPanel.module.css new file mode 100644 index 0000000..f3906d0 --- /dev/null +++ b/PageManager.Web/src/components/DetailPanel/DetailPanel.module.css @@ -0,0 +1,259 @@ +/* MD3 Standard Side Sheet */ +.scrim { + display: none; /* hidden on wide screens; modal on narrow */ +} + +.sheet { + width: 360px; + min-width: 360px; + height: 100%; + background: var(--md-sys-color-surface-container-low); + display: flex; + flex-direction: column; + overflow-y: auto; + transform: translateX(100%); + transition: transform 250ms cubic-bezier(.3,0,0,1); + flex-shrink: 0; +} + +.sheet.open { + transform: translateX(0); +} + +.header { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 8px 0; + flex-shrink: 0; +} + +/* MD3 Icon Button */ +.closeBtn { + width: 40px; + height: 40px; + border-radius: var(--md-sys-shape-full); + display: flex; + align-items: center; + justify-content: center; + color: var(--md-sys-color-on-surface-variant); + position: relative; + overflow: hidden; + transition: color 200ms; +} + +.closeBtn::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--md-sys-color-on-surface-variant); + opacity: 0; + transition: opacity 200ms; +} + +.closeBtn:hover::before { opacity: .08; } +.closeBtn:active::before { opacity: .12; } + +.heading { + font: var(--md-sys-typescale-title-large); + color: var(--md-sys-color-on-surface); +} + +.cover { + margin: 12px 16px; + height: 180px; + border-radius: var(--md-sys-shape-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.coverImg { + height: 100%; + width: 100%; + object-fit: contain; + border-radius: var(--md-sys-shape-sm); +} + +.coverInitials { + font-size: 3rem; + font-weight: 300; + color: rgba(255,255,255,.3); + letter-spacing: .05em; +} + +.body { + padding: 0 16px 24px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.title { + font: var(--md-sys-typescale-headline-small); + color: var(--md-sys-color-on-surface); + margin-top: 4px; +} + +.author { + font: var(--md-sys-typescale-body-large); + color: var(--md-sys-color-on-surface-variant); +} + +.series { + font: var(--md-sys-typescale-label-large); + color: var(--md-sys-color-primary); +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +/* MD3 Suggestion Chip */ +.formatChip { + height: 32px; + padding: 0 12px; + border-radius: var(--md-sys-shape-sm); + border: 1px solid var(--md-sys-color-outline-variant); + background: var(--md-sys-color-surface-container-highest); + font: var(--md-sys-typescale-label-large); + color: var(--md-sys-color-on-surface); + display: flex; + align-items: center; +} + +.divider { + height: 1px; + background: var(--md-sys-color-outline-variant); + margin: 4px 0; +} + +.stats { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stat { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.statIcon { + color: var(--md-sys-color-on-surface-variant); + font-size: 20px !important; + margin-top: 2px; +} + +.statLabel { + font: var(--md-sys-typescale-label-small); + color: var(--md-sys-color-on-surface-variant); + text-transform: uppercase; + letter-spacing: .06em; +} + +.statValue { + font: var(--md-sys-typescale-body-large); + color: var(--md-sys-color-on-surface); +} + +.genres { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* MD3 Filter Chip */ +.genreChip { + height: 32px; + padding: 0 12px; + border-radius: var(--md-sys-shape-sm); + border: 1px solid var(--md-sys-color-outline-variant); + font: var(--md-sys-typescale-label-large); + color: var(--md-sys-color-on-surface-variant); + display: flex; + align-items: center; + background: transparent; +} + +.description { + font: var(--md-sys-typescale-body-medium); + color: var(--md-sys-color-on-surface-variant); + line-height: 1.6; + padding-top: 4px; +} + +.actions { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +/* MD3 Filled Button */ +.btnFilled { + height: 40px; + padding: 0 24px; + border-radius: var(--md-sys-shape-full); + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + font: var(--md-sys-typescale-label-large); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + position: relative; + overflow: hidden; + transition: box-shadow 200ms; +} + +.btnFilled .material-symbols-outlined { font-size: 18px !important; } + +.btnFilled::before { + content: ''; + position: absolute; + inset: 0; + background: var(--md-sys-color-on-primary); + opacity: 0; + transition: opacity 200ms; +} +.btnFilled:hover { box-shadow: var(--md-sys-elevation-1); } +.btnFilled:hover::before { opacity: .08; } +.btnFilled:active::before { opacity: .12; } + +/* MD3 Filled Tonal Button */ +.btnTonal { + height: 40px; + padding: 0 24px; + border-radius: var(--md-sys-shape-full); + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + font: var(--md-sys-typescale-label-large); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + position: relative; + overflow: hidden; + transition: box-shadow 200ms; +} + +.btnTonal .material-symbols-outlined { font-size: 18px !important; } + +.btnTonal::before { + content: ''; + position: absolute; + inset: 0; + background: var(--md-sys-color-on-secondary-container); + opacity: 0; + transition: opacity 200ms; +} +.btnTonal:hover { box-shadow: var(--md-sys-elevation-1); } +.btnTonal:hover::before { opacity: .08; } +.btnTonal:active::before { opacity: .12; } diff --git a/PageManager.Web/src/components/DetailPanel/DetailPanel.tsx b/PageManager.Web/src/components/DetailPanel/DetailPanel.tsx new file mode 100644 index 0000000..77ddf5d --- /dev/null +++ b/PageManager.Web/src/components/DetailPanel/DetailPanel.tsx @@ -0,0 +1,97 @@ +import type { Book } from '../../types' +import s from './DetailPanel.module.css' + +interface Props { + book: Book | null + onClose: () => void +} + +export default function DetailPanel({ book, onClose }: Props) { + return ( + <> + {book &&
} + + + ) +} + +function Stat({ icon, label, value }: { icon: string; label: string; value: string }) { + return ( +
+ {icon} +
+

{label}

+

{value}

+
+
+ ) +} diff --git a/PageManager.Web/src/components/MetadataForm/MetadataForm.module.css b/PageManager.Web/src/components/MetadataForm/MetadataForm.module.css new file mode 100644 index 0000000..01b633a --- /dev/null +++ b/PageManager.Web/src/components/MetadataForm/MetadataForm.module.css @@ -0,0 +1,224 @@ +.form { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px 24px 24px; + overflow-y: auto; + flex: 1; +} + +.row { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.field { + min-width: 0; +} + +.fieldFull { + flex: 1; +} + +/* ── MD3 Outlined Text Field ── */ +.inputWrap { + position: relative; + height: 56px; +} + +.textareaWrap { + height: auto; +} + +.input { + width: 100%; + height: 100%; + padding: 16px; + background: transparent; + border: none; + border-radius: var(--md-sys-shape-xs); + font: var(--md-sys-typescale-body-large); + color: var(--md-sys-color-on-surface); + outline: none; + position: relative; + z-index: 1; +} + +.textarea { + height: auto; + resize: vertical; + padding-top: 20px; +} + +/* The visible border is the fieldset */ +.fieldset { + position: absolute; + inset: -5px 0 0; + border: 1px solid var(--md-sys-color-outline); + border-radius: var(--md-sys-shape-xs); + pointer-events: none; + margin: 0; + padding: 0 8px; + transition: border-color 200ms, border-width 200ms; +} + +.legend { + font-size: .75rem; + line-height: 0; + padding: 0; + width: 0; /* collapsed by default; expands on focus/filled */ + overflow: hidden; + white-space: nowrap; + transition: width 200ms cubic-bezier(.2,0,0,1); + visibility: hidden; +} + +.label { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + font: var(--md-sys-typescale-body-large); + color: var(--md-sys-color-on-surface-variant); + pointer-events: none; + transition: top 150ms cubic-bezier(.2,0,0,1), + font-size 150ms cubic-bezier(.2,0,0,1), + line-height 150ms cubic-bezier(.2,0,0,1), + color 150ms; + z-index: 2; + background: transparent; +} + +.labelTextarea { + top: 20px; + transform: none; +} + +/* Floating label when input has value or is focused */ +.input:focus ~ .label, +.input:not(:placeholder-shown) ~ .label { + top: 0; + transform: translateY(-50%); + font-size: .75rem; + line-height: 1rem; + background: var(--md-sys-color-surface-container-low); + padding: 0 4px; + left: 12px; +} + +.input:focus ~ .label { + color: var(--md-sys-color-primary); +} + +.input:focus ~ .fieldset { + border-color: var(--md-sys-color-primary); + border-width: 2px; +} + +.input:focus ~ .fieldset .legend, +.input:not(:placeholder-shown) ~ .fieldset .legend { + width: auto; + padding: 0 2px; +} + +.textarea:focus ~ .label, +.textarea:not(:placeholder-shown) ~ .label { + top: 0; + transform: translateY(-50%); + font-size: .75rem; + line-height: 1rem; + background: var(--md-sys-color-surface-container-low); + padding: 0 4px; + left: 12px; +} + +.textarea:focus ~ .label { + color: var(--md-sys-color-primary); +} + +.textarea:focus ~ .fieldset { + border-color: var(--md-sys-color-primary); + border-width: 2px; +} + +.supporting { + font: var(--md-sys-typescale-body-small); + color: var(--md-sys-color-on-surface-variant); + padding: 4px 16px 0; +} + +/* ── Buttons ── */ +.footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + margin-top: auto; + padding-top: 8px; +} + +/* MD3 Outlined Button */ +.btnOutlined { + height: 40px; + padding: 0 24px; + border-radius: var(--md-sys-shape-full); + border: 1px solid var(--md-sys-color-outline); + font: var(--md-sys-typescale-label-large); + color: var(--md-sys-color-primary); + display: flex; + align-items: center; + gap: 8px; + position: relative; + overflow: hidden; + background: transparent; + transition: box-shadow 200ms; +} + +.btnOutlined .material-symbols-outlined { font-size: 18px !important; } + +.btnOutlined::before { + content: ''; + position: absolute; + inset: 0; + background: var(--md-sys-color-primary); + opacity: 0; + transition: opacity 200ms; +} +.btnOutlined:hover::before { opacity: .08; } +.btnOutlined:active::before { opacity: .12; } + +/* MD3 Filled Button */ +.btnFilled { + height: 40px; + padding: 0 24px; + border-radius: var(--md-sys-shape-full); + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + font: var(--md-sys-typescale-label-large); + display: flex; + align-items: center; + gap: 8px; + position: relative; + overflow: hidden; + transition: box-shadow 200ms, background 300ms; + min-width: 80px; + justify-content: center; +} + +.btnFilled .material-symbols-outlined { font-size: 18px !important; } + +.btnFilled::before { + content: ''; + position: absolute; + inset: 0; + background: var(--md-sys-color-on-primary); + opacity: 0; + transition: opacity 200ms; +} +.btnFilled:hover { box-shadow: var(--md-sys-elevation-1); } +.btnFilled:hover::before { opacity: .08; } + +.btnSaved { + background: var(--md-sys-color-success); +} diff --git a/PageManager.Web/src/components/MetadataForm/MetadataForm.tsx b/PageManager.Web/src/components/MetadataForm/MetadataForm.tsx new file mode 100644 index 0000000..a1865ec --- /dev/null +++ b/PageManager.Web/src/components/MetadataForm/MetadataForm.tsx @@ -0,0 +1,137 @@ +import { useEffect, useId, useState } from 'react' +import type { Book } from '../../types' +import { toForm } from './utils' +import type { FormState } from './utils' +import s from './MetadataForm.module.css' + +interface Props { + book: Book + onSave: (patch: Partial) => void +} + +export default function MetadataForm({ book, onSave }: Props) { + const [form, setForm] = useState(() => toForm(book)) + const [saved, setSaved] = useState(false) + + useEffect(() => { + setForm(toForm(book)) + setSaved(false) + }, [book.id]) + + const set = (field: keyof FormState) => + (e: React.ChangeEvent) => + setForm(f => ({ ...f, [field]: e.target.value })) + + function handleSave(e: React.FormEvent) { + e.preventDefault() + onSave({ + title: form.title, + publisher: form.publisher || null, + year: form.year ? Number(form.year) : null, + pages: form.pages ? Number(form.pages) : null, + genres: form.genres.split(',').map(g => g.trim()).filter(Boolean), + description: form.description || null, + }) + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } + + return ( +
+
+ +
+
+ +
+
+ + +
+
+ + + +
+
+ +
+
+ +
+ +
+ + +
+
+ ) +} + +/* ── MD3 Outlined Text Field ─────────────────────────────── */ + +interface FieldProps { + label: string + value: string + onChange: (e: React.ChangeEvent) => void + grow?: boolean + width?: number + supporting?: string + type?: string +} + +function OutlinedField({ label, value, onChange, grow, width, supporting, type = 'text' }: FieldProps) { + const id = useId() + return ( +
+
+ + +
{label}
+
+ {supporting &&

{supporting}

} +
+ ) +} + +interface TextareaProps { + label: string + value: string + onChange: (e: React.ChangeEvent) => void +} + +function OutlinedTextarea({ label, value, onChange }: TextareaProps) { + const id = useId() + return ( +
+
+