From bba45a1689cf4da3b3d84dc04d9c9bdcb740d9f5 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 16:55:51 +0200 Subject: [PATCH 01/35] Dump version 2.4.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index d4cacdfb..0a5f0828 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ - 2.3.0 + 2.4.0 From 62d401055d03bcc5b56e00fa5e1c099da8c3be82 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 17:39:54 +0200 Subject: [PATCH 02/35] Add import books from amazon --- .../Import/AmazonImportApiClient.cs | 28 ++ .../Components/Import/AmazonImportPage.razor | 244 ++++++++++++++++++ .../Components/Import/ImportPage.razor | 11 + ...frastructureServiceCollectionExtensions.cs | 2 + src/Domain/Domain.csproj | 6 + src/Domain/Models/AmazonBookImportPlan.cs | 17 ++ .../Models/AmazonBookImportRequestItem.cs | 16 ++ src/Domain/Models/AmazonOrderPreviewRow.cs | 46 ++++ .../Services/AmazonBookImportMergeService.cs | 91 +++++++ .../Services/AmazonOrderPreviewService.cs | 151 +++++++++++ .../Dto/AmazonImportCommitItemDto.cs | 35 +++ .../Dto/AmazonImportCommitRequestDto.cs | 11 + .../Dto/AmazonImportCommitResultDto.cs | 22 ++ .../Dto/AmazonOrderPreviewRowDto.cs | 63 +++++ .../Controllers/AmazonImportController.cs | 103 ++++++++ .../Mappers/AmazonOrderPreviewRowDtoMapper.cs | 15 ++ src/WebApi/Program.cs | 1 + .../Resources/AmazonFixtureCsvBuilder.cs | 32 +++ .../Resources/AmazonImportResourceTest.cs | 81 ++++++ .../AmazonBookImportMergeServiceTest.cs | 74 ++++++ .../Services/AmazonOrderPreviewServiceTest.cs | 93 +++++++ 21 files changed, 1142 insertions(+) create mode 100644 src/BlazorApp/Components/Import/AmazonImportApiClient.cs create mode 100644 src/BlazorApp/Components/Import/AmazonImportPage.razor create mode 100644 src/Domain/Models/AmazonBookImportPlan.cs create mode 100644 src/Domain/Models/AmazonBookImportRequestItem.cs create mode 100644 src/Domain/Models/AmazonOrderPreviewRow.cs create mode 100644 src/Domain/Services/AmazonBookImportMergeService.cs create mode 100644 src/Domain/Services/AmazonOrderPreviewService.cs create mode 100644 src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs create mode 100644 src/WebApi.Contracts/Dto/AmazonImportCommitRequestDto.cs create mode 100644 src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs create mode 100644 src/WebApi.Contracts/Dto/AmazonOrderPreviewRowDto.cs create mode 100644 src/WebApi/Controllers/AmazonImportController.cs create mode 100644 src/WebApi/Mappers/AmazonOrderPreviewRowDtoMapper.cs create mode 100644 test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs create mode 100644 test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs create mode 100644 test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs create mode 100644 test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs diff --git a/src/BlazorApp/Components/Import/AmazonImportApiClient.cs b/src/BlazorApp/Components/Import/AmazonImportApiClient.cs new file mode 100644 index 00000000..5571198c --- /dev/null +++ b/src/BlazorApp/Components/Import/AmazonImportApiClient.cs @@ -0,0 +1,28 @@ +using System.Net.Http.Headers; +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Import; + +public sealed class AmazonImportApiClient(HttpClient http) +{ + public async Task> PreviewAsync(Stream csvStream, string fileName) + { + using var content = new MultipartFormDataContent(); + using var fileContent = new StreamContent(csvStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/csv"); + content.Add(fileContent, "file", fileName); + + var response = await http.PostAsync("/api/import/amazon/preview", content); + response.EnsureSuccessStatusCode(); + + return (await response.Content.ReadFromJsonAsync>())!; + } + + public async Task CommitAsync(List items) + { + var response = await http.PostAsJsonAsync("/api/import/amazon/commit", new AmazonImportCommitRequestDto { Items = items }); + response.EnsureSuccessStatusCode(); + + return (await response.Content.ReadFromJsonAsync())!; + } +} diff --git a/src/BlazorApp/Components/Import/AmazonImportPage.razor b/src/BlazorApp/Components/Import/AmazonImportPage.razor new file mode 100644 index 00000000..60140dff --- /dev/null +++ b/src/BlazorApp/Components/Import/AmazonImportPage.razor @@ -0,0 +1,244 @@ +@page "/import/amazon" +@attribute [Authorize] +@using Keeptrack.WebApi.Contracts.Dto + +
+

Import from Amazon

+
+ +
+

+ Upload the order-history CSV from Amazon's "Request My Data" export. + Amazon's export has no category column, so every row is shown for you to review - only books are supported so far. + Nothing is imported until you click "Import selected" below. +

+ +
+ + +
+ + @if (_previewing) + { +

Reading your export…

+ } + + @if (_error is not null) + { +
@_error
+ } +
+ +@if (_rows is not null) +{ +
+
+
@DisplayedRows.Count() row(s) shown
+ +
+ +
+ + + + + + + + + + + + + + @foreach (var row in DisplayedRows) + { + + + + + + + + + + } + +
+ + TitleYearCopyOrder infoVendorReference
+ + @if (row.Preview.AlreadyImported) + { + already imported + } + + + + @if (row.Isbn is not null) + { +
@row.Isbn
+ } + @if (row.AcquiredAt is not null) + { +
@row.AcquiredAt.Value.ToString("yyyy-MM-dd")
+ } + @if (row.Price is not null) + { +
@row.Price.Value.ToString("0.00") €
+ } +
@(row.Vendor ?? "—")@(row.Reference ?? "—")
+
+ + +
+} + +@if (_commitError is not null) +{ +
@_commitError
+} + +@if (_commitResult is not null) +{ +
+

Books created: @_commitResult.BooksCreated

+

Existing books updated with a new copy: @_commitResult.BooksMergedInto

+

Owned versions added: @_commitResult.OwnedVersionsAdded

+
+} + +@code { + private const long MaxFileSize = 20_000_000; + + [Inject] private AmazonImportApiClient ImportApi { get; set; } = null!; + + private bool _previewing; + private string? _error; + private List? _rows; + private bool _showAll; + + private bool _committing; + private string? _commitError; + private AmazonImportCommitResultDto? _commitResult; + + private IEnumerable DisplayedRows => _rows is null ? [] : _showAll ? _rows : _rows.Where(r => r.Preview.LooksLikeBook); + + private IEnumerable SelectedRows => _rows is null ? [] : _rows.Where(r => r.Selected); + + /// Drives the header checkbox: checked only once every currently-shown row is selected. + private bool AllDisplayedSelected => DisplayedRows.Any() && DisplayedRows.All(r => r.Selected); + + /// Applies to whatever currently shows, not the whole underlying list - + /// e.g. selecting all while only likely-books are shown never touches the hidden non-book rows. + private void SetAllDisplayedSelected(bool selected) + { + foreach (var row in DisplayedRows) + { + row.Selected = selected; + } + } + + private async Task OnFileSelectedAsync(InputFileChangeEventArgs e) + { + _previewing = true; + _error = null; + _rows = null; + _commitResult = null; + _commitError = null; + + try + { +#pragma warning disable S5693 + await using var stream = e.File.OpenReadStream(MaxFileSize); +#pragma warning restore S5693 + var preview = await ImportApi.PreviewAsync(stream, e.File.Name); + _rows = preview.Select(ToEditableRow).ToList(); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _previewing = false; + } + } + + private async Task CommitAsync() + { + _committing = true; + _commitError = null; + _commitResult = null; + + try + { + var items = SelectedRows.Select(row => new AmazonImportCommitItemDto + { + RowId = row.Preview.RowId, + Title = row.Title, + // Amazon's export has no publication year (only the purchase date), so it's never pre-filled - + // but worth entering by hand here if known, since it sharpens the deferred reference-lookup + // pass's match precision later (an ambiguous multi-candidate title+year search is left + // unresolved for manual review rather than guessed, same as everywhere else in the app). + Year = row.Year, + Isbn = row.Isbn, + AcquiredAt = row.AcquiredAt, + Price = row.Price, + Vendor = row.Vendor, + Reference = row.Reference, + CopyType = row.CopyType + }).ToList(); + + _commitResult = await ImportApi.CommitAsync(items); + } + catch (Exception ex) + { + _commitError = ex.Message; + } + finally + { + _committing = false; + } + } + + private static EditableRow ToEditableRow(AmazonOrderPreviewRowDto preview) => new() + { + Preview = preview, + Selected = preview.LooksLikeBook && !preview.AlreadyImported, + Title = preview.Title, + Isbn = preview.SuggestedIsbn, + AcquiredAt = preview.OrderDate, + Price = preview.Price, + Vendor = preview.Vendor, + // Mirrors AmazonBookImportMergeService.FormatOrderReference's format server-side - this is just + // the review UI's own display/commit value, not import logic, so it stays a plain string here. + Reference = $"Amazon order {preview.OrderId}" + }; + + private sealed class EditableRow + { + public required AmazonOrderPreviewRowDto Preview { get; init; } + public bool Selected { get; set; } + + /// The one field worth editing at import time - everything else below is read-only, order-derived info. + public string Title { get; set; } = ""; + + /// Never pre-filled (Amazon's export has no publication year) - worth entering by hand if known, see . + public int? Year { get; set; } + + public string? Isbn { get; set; } + public DateOnly? AcquiredAt { get; set; } + public decimal? Price { get; set; } + public string? Vendor { get; set; } + public string? Reference { get; set; } + + /// The other field worth a decision - Amazon's export can't reliably tell physical from digital. + public CopyType CopyType { get; set; } + } +} diff --git a/src/BlazorApp/Components/Import/ImportPage.razor b/src/BlazorApp/Components/Import/ImportPage.razor index 6f5830b2..d932b8c4 100644 --- a/src/BlazorApp/Components/Import/ImportPage.razor +++ b/src/BlazorApp/Components/Import/ImportPage.razor @@ -2,6 +2,17 @@ @attribute [Authorize]
+

Import from Amazon

+
+ +
+

+ Review your Amazon order history and pick which items to import as books (only books are supported so far). +

+ Import from Amazon +
+ +

Import from TV Time

diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 53dff133..3e2ddb9e 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -43,6 +43,8 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj index ff76858c..9505dfaf 100644 --- a/src/Domain/Domain.csproj +++ b/src/Domain/Domain.csproj @@ -11,4 +11,10 @@ + + + + + diff --git a/src/Domain/Models/AmazonBookImportPlan.cs b/src/Domain/Models/AmazonBookImportPlan.cs new file mode 100644 index 00000000..87b32a21 --- /dev/null +++ b/src/Domain/Models/AmazonBookImportPlan.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +/// +/// What decided: brand new books to +/// create, and existing (or already-created-this-batch) books to update - each already carrying the extra +/// appended to its . +/// +public class AmazonBookImportPlan +{ + public List BooksToCreate { get; set; } = []; + + public List BooksToUpdate { get; set; } = []; + + public int OwnedVersionsAdded { get; set; } +} diff --git a/src/Domain/Models/AmazonBookImportRequestItem.cs b/src/Domain/Models/AmazonBookImportRequestItem.cs new file mode 100644 index 00000000..6e838dfc --- /dev/null +++ b/src/Domain/Models/AmazonBookImportRequestItem.cs @@ -0,0 +1,16 @@ +namespace Keeptrack.Domain.Models; + +/// +/// One user-selected/edited row from the review UI, already translated from the web contract - the input +/// to . +/// +public class AmazonBookImportRequestItem +{ + public required string Title { get; set; } + + public int? Year { get; set; } + + public string? Isbn { get; set; } + + public required OwnedVersionModel OwnedVersion { get; set; } +} diff --git a/src/Domain/Models/AmazonOrderPreviewRow.cs b/src/Domain/Models/AmazonOrderPreviewRow.cs new file mode 100644 index 00000000..e2ebaa2d --- /dev/null +++ b/src/Domain/Models/AmazonOrderPreviewRow.cs @@ -0,0 +1,46 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// One line item parsed from an Amazon order-history export, before the user has reviewed/selected it. +/// Transient - never persisted. Produced by and mapped +/// to AmazonOrderPreviewRowDto by AmazonOrderPreviewRowDtoMapper for the review UI. +/// +public class AmazonOrderPreviewRow +{ + /// + /// "{OrderId}:{Asin}" - stable within one export, used only to correlate a selected/edited row + /// back to this one at commit time. Never stored. + /// + public required string RowId { get; set; } + + /// Amazon's product name, with the export's mojibake repaired. + public required string Title { get; set; } + + public required string Asin { get; set; } + + public required string OrderId { get; set; } + + public DateOnly? OrderDate { get; set; } + + /// What was actually paid for this item - tax and any per-item discount already netted in. + public decimal? Price { get; set; } + + public required string Vendor { get; set; } + + /// Display only ("New", "Used"...) - not carried onto the created book. + public string? Condition { get; set; } + + /// True when passes an ISBN-10 checksum - the review table's default filter. + public bool LooksLikeBook { get; set; } + + /// again, only when is true. + public string? SuggestedIsbn { get; set; } + + /// + /// True when an existing book already has an owned version referencing this order - see + /// . + /// + public bool AlreadyImported { get; set; } +} diff --git a/src/Domain/Services/AmazonBookImportMergeService.cs b/src/Domain/Services/AmazonBookImportMergeService.cs new file mode 100644 index 00000000..96d44f4e --- /dev/null +++ b/src/Domain/Services/AmazonBookImportMergeService.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Computes what to create/update when committing a set of user-reviewed Amazon order rows as books. Pure: +/// takes the owner's already-fetched books and the selected rows, returns a plan - all repository access +/// stays in AmazonImportController. Matching is by normalized title (, +/// the same "same title" rule the TV Time import already established), merging within the same commit +/// batch too: two selected rows sharing a title become one book with two owned versions, even if neither +/// existed before this commit. +/// +public static class AmazonBookImportMergeService +{ + private const string OrderReferencePrefix = "Amazon order "; + + /// + /// The one place that formats an owned version's for an + /// imported order - human-readable, and also the dedup key looks for + /// on a later re-import. + /// + public static string FormatOrderReference(string orderId) => OrderReferencePrefix + orderId; + + /// + /// Order ids already referenced by an existing owned version - used at preview time to flag rows that + /// look like they were imported before, so re-uploading a newer export doesn't duplicate them. + /// + public static HashSet FindImportedOrderIds(IEnumerable existingBooks) => + existingBooks + .SelectMany(book => book.OwnedVersions) + .Select(version => version.Reference) + .Where(reference => reference is not null && reference.StartsWith(OrderReferencePrefix, StringComparison.Ordinal)) + .Select(reference => reference![OrderReferencePrefix.Length..]) + .ToHashSet(); + + public static AmazonBookImportPlan ComputeCommitPlan(string ownerId, IReadOnlyCollection existingBooks, IReadOnlyList items) + { + var plan = new AmazonBookImportPlan(); + + var byNormalizedTitle = new Dictionary(); + foreach (var book in existingBooks) + { + byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(book.Title), book); + } + + // Books created earlier in this same batch are tracked separately from pre-existing ones: a later + // row matching one must only get its owned version appended (already reflected via BooksToCreate), + // never also queued onto BooksToUpdate - it has no Id yet, so "updating" it would be meaningless. + var createdThisBatch = new HashSet(); + + foreach (var item in items) + { + var key = TitleNormalizer.Normalize(item.Title); + + if (byNormalizedTitle.TryGetValue(key, out var existing)) + { + existing.OwnedVersions.Add(item.OwnedVersion); + if (!createdThisBatch.Contains(existing) && !plan.BooksToUpdate.Contains(existing)) + { + plan.BooksToUpdate.Add(existing); + } + } + else + { + var book = new BookModel + { + OwnerId = ownerId, + Title = item.Title, + Author = string.Empty, + Year = item.Year, + Isbn = item.Isbn, + OwnedVersions = [item.OwnedVersion] + }; + plan.BooksToCreate.Add(book); + createdThisBatch.Add(book); + + // so a later row in the same batch sharing this title merges into it too, instead of + // creating a second book + byNormalizedTitle[key] = book; + } + + plan.OwnedVersionsAdded++; + } + + return plan; + } +} diff --git a/src/Domain/Services/AmazonOrderPreviewService.cs b/src/Domain/Services/AmazonOrderPreviewService.cs new file mode 100644 index 00000000..f806e75d --- /dev/null +++ b/src/Domain/Services/AmazonOrderPreviewService.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using CsvHelper; +using CsvHelper.Configuration; +using CsvHelper.Configuration.Attributes; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Parses an Amazon.fr order-history export ("Request My Data" -> "Your Orders") into review rows. Pure: +/// bytes in, rows out, no repository access - below is computed by +/// the caller from already-fetched data so this stays testable without a database. +/// Amazon's export has no category column at all, so this never decides "is this a book" on its own - it +/// only computes as a checksum-verified suggestion for +/// the review UI's default filter, confirmed against a real export (an Amazon book's ASIN is routinely its +/// ISBN-10). The user makes the actual call. +/// +public static class AmazonOrderPreviewService +{ + private sealed class AmazonOrderRecord + { + [Name("ASIN")] + public required string Asin { get; set; } + + [Name("Order Date")] + public required string OrderDate { get; set; } + + [Name("Order ID")] + public required string OrderId { get; set; } + + [Name("Product Name")] + public required string ProductName { get; set; } + + [Name("Product Condition")] + public string? ProductCondition { get; set; } + + [Name("Total Amount")] + public string? TotalAmount { get; set; } + + [Name("Website")] + public string? Website { get; set; } + } + + private static readonly CsvConfiguration s_csvConfiguration = new(CultureInfo.InvariantCulture) + { + PrepareHeaderForMatch = args => args.Header.Trim().ToLowerInvariant() + }; + + public static List BuildPreview(Stream csvStream, IReadOnlySet alreadyImportedOrderIds) + { + using var reader = new StreamReader(csvStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + using var csv = new CsvReader(reader, s_csvConfiguration); + var records = csv.GetRecords().ToList(); + + return records.Select(record => + { + var suggestedIsbn = IsValidIsbn10(record.Asin) ? record.Asin : null; + + return new AmazonOrderPreviewRow + { + RowId = $"{record.OrderId}:{record.Asin}", + // Some product names also carry a raw numeric HTML entity instead of the character itself + // (confirmed in a real export: "Le Dernier Vœu" instead of "Le Dernier Vœu"). Decoded + // after the mojibake repair, not before - the entity text is plain ASCII, so it round-trips + // through that repair unchanged regardless of order, but repairing first keeps the two + // independent fixes from interacting in either direction. + Title = WebUtility.HtmlDecode(FixMojibake(record.ProductName)), + Asin = record.Asin, + OrderId = record.OrderId, + OrderDate = ParseOrderDate(record.OrderDate), + Price = ParsePrice(record.TotalAmount), + Vendor = record.Website ?? string.Empty, + Condition = record.ProductCondition, + LooksLikeBook = suggestedIsbn is not null, + SuggestedIsbn = suggestedIsbn, + AlreadyImported = alreadyImportedOrderIds.Contains(record.OrderId) + }; + }).ToList(); + } + + /// + /// Amazon's export is mojibake for accented text: UTF-8 bytes were re-encoded as if they were Latin-1 + /// (confirmed against the raw bytes of a real export - "protection d'écrans" reads back as "protection + /// d'écrans"). Reinterpreting the decoded chars as Latin-1 bytes and re-decoding as UTF-8 repairs it. + /// The repaired text is only used when it contains no U+FFFD replacement character, so already-correct + /// text (a lone, validly-encoded accented char has no valid single-byte UTF-8 reading) round-trips + /// untouched instead of being corrupted a second time. + /// + private static string FixMojibake(string text) + { + if (text.Length == 0 || text.Any(c => c > 255)) return text; + + var bytes = new byte[text.Length]; + for (var i = 0; i < text.Length; i++) + { + bytes[i] = (byte)text[i]; + } + + var repaired = Encoding.UTF8.GetString(bytes); + return repaired.Contains('�') ? text : repaired; + } + + /// + /// Strips Amazon's Excel-formula-injection-prevention leading apostrophe (confirmed in a real export's + /// "Total Discounts" column, e.g. the literal text '-5') before parsing. + /// + private static decimal? ParsePrice(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + + var cleaned = raw.Trim().Trim('\''); + return decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var value) ? value : null; + } + + private static DateOnly? ParseOrderDate(string raw) => + DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? DateOnly.FromDateTime(parsed.UtcDateTime) + : null; + + /// + /// ISBN-10 checksum: 10 digits (last one may be 'X'/'x' meaning check value 10), weighted sum over + /// position (10 down to 1) divisible by 11. Amazon's book ASINs are routinely a real ISBN-10, so this + /// is a strong, false-positive-resistant "is this a book" signal - unlike a bare "10 characters" check. + /// + private static bool IsValidIsbn10(string asin) + { + if (asin.Length != 10) return false; + + var sum = 0; + for (var i = 0; i < 9; i++) + { + if (!char.IsDigit(asin[i])) return false; + sum += (asin[i] - '0') * (10 - i); + } + + var last = asin[9]; + int checkDigit; + if (last is 'X' or 'x') checkDigit = 10; + else if (char.IsDigit(last)) checkDigit = last - '0'; + else return false; + + sum += checkDigit; + return sum % 11 == 0; + } +} diff --git a/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs b/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs new file mode 100644 index 00000000..ec277e3f --- /dev/null +++ b/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs @@ -0,0 +1,35 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One row selected for import, carrying whatever the user edited in the review table. +/// +public class AmazonImportCommitItemDto +{ + /// + /// The this came from. Not used for anything server-side + /// beyond echoing it back in error messages - the row's data is taken entirely from this DTO's own fields. + /// + public required string RowId { get; set; } + + public required string Title { get; set; } + + /// + /// Publication year, if the user happens to know it - Amazon's export has no source for this (order + /// date is the purchase year, not the book's), so it's never auto-filled. + /// + public int? Year { get; set; } + + public string? Isbn { get; set; } + + public DateOnly? AcquiredAt { get; set; } + + public decimal? Price { get; set; } + + public string? Vendor { get; set; } + + public string? Reference { get; set; } + + public CopyType CopyType { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/AmazonImportCommitRequestDto.cs b/src/WebApi.Contracts/Dto/AmazonImportCommitRequestDto.cs new file mode 100644 index 00000000..cd709c35 --- /dev/null +++ b/src/WebApi.Contracts/Dto/AmazonImportCommitRequestDto.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// The set of Amazon order-history rows the user picked in the review UI, ready to be created as books. +/// +public class AmazonImportCommitRequestDto +{ + public required List Items { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs b/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs new file mode 100644 index 00000000..41fb07ab --- /dev/null +++ b/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs @@ -0,0 +1,22 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Outcome of committing a selected set of Amazon order rows as books. +/// +public class AmazonImportCommitResultDto +{ + /// + /// Brand new books created. + /// + public int BooksCreated { get; set; } + + /// + /// Existing books (including ones created earlier in this same commit) that received an additional owned version. + /// + public int BooksMergedInto { get; set; } + + /// + /// Total owned versions added across both created and merged-into books. + /// + public int OwnedVersionsAdded { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/AmazonOrderPreviewRowDto.cs b/src/WebApi.Contracts/Dto/AmazonOrderPreviewRowDto.cs new file mode 100644 index 00000000..78b711c4 --- /dev/null +++ b/src/WebApi.Contracts/Dto/AmazonOrderPreviewRowDto.cs @@ -0,0 +1,63 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One line item parsed from an uploaded Amazon order-history export, awaiting the user's review before +/// anything is imported. See for what gets sent back once selected. +/// +public class AmazonOrderPreviewRowDto +{ + /// + /// Correlates a selected/edited row back to this one at commit time. Stable within one export, never stored. + /// + public required string RowId { get; set; } + + /// + /// Amazon's product name, with the export's mojibake repaired. + /// + public required string Title { get; set; } + + /// + /// Amazon's own product identifier for this order line. + /// + public required string Asin { get; set; } + + /// + /// Amazon's order number. + /// + public required string OrderId { get; set; } + + public DateOnly? OrderDate { get; set; } + + /// + /// What was actually paid for this item - tax and any per-item discount already netted in. + /// + public decimal? Price { get; set; } + + /// + /// The storefront the order was placed on (e.g. "Amazon.fr"). + /// + public required string Vendor { get; set; } + + /// + /// Display only ("New", "Used"...) - not carried onto the created book. + /// + public string? Condition { get; set; } + + /// + /// True when passes an ISBN-10 checksum - the review table's default filter. + /// + public bool LooksLikeBook { get; set; } + + /// + /// again, only when is true. + /// + public string? SuggestedIsbn { get; set; } + + /// + /// True when an existing book already has an owned version referencing this order. Defaults to + /// unchecked in the review UI, but stays editable/selectable in case the user wants to re-import anyway. + /// + public bool AlreadyImported { get; set; } +} diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs new file mode 100644 index 00000000..b08d582a --- /dev/null +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -0,0 +1,103 @@ +using System.Diagnostics.CodeAnalysis; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Domain.Services; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +/// +/// Previews an Amazon.fr order-history export and commits the rows the user selected/edited in the review +/// UI as books (the only supported target for now - see ). +/// Synchronous on both ends: unlike the TV Time import, there is no external API call in the loop, so even +/// a multi-year export completes well within a normal request. +/// +[ApiController] +[Authorize(Policy = "MemberOnly")] +[Route("api/import/amazon")] +public class AmazonImportController(IBookRepository bookRepository, AmazonOrderPreviewRowDtoMapper previewMapper) : ControllerBase +{ + /// + /// Parses the uploaded order-history CSV and returns every line item for review - nothing is persisted + /// by this call. Amazon's export carries no category column, so every row is returned; the review UI + /// defaults to showing only rows. + /// + [HttpPost("preview")] + [RequestSizeLimit(20_000_000)] + [Consumes("multipart/form-data")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + [SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", + Justification = "The limit IS set (20 MB), deliberately above Sonar's 8 MB default: a multi-year Amazon order-history " + + "export can be sizeable, and the endpoint is authenticated, member-only, admin-of-your-own-data.")] + public async Task>> Preview(IFormFile file) + { + if (file.Length == 0) + { + return BadRequest(); + } + + var ownerId = this.GetUserId(); + var existingBooks = await FindAllBooksAsync(ownerId); + var alreadyImportedOrderIds = AmazonBookImportMergeService.FindImportedOrderIds(existingBooks); + + await using var stream = file.OpenReadStream(); + var rows = AmazonOrderPreviewService.BuildPreview(stream, alreadyImportedOrderIds); + + return Ok(rows.Select(previewMapper.ToDto).ToList()); + } + + /// + /// Creates/updates books from the rows the user selected in the review UI. A row whose (normalized) + /// title matches an existing book - or a book created earlier in this same request - gets an + /// additional owned version instead of a duplicate book; see + /// . + /// + [HttpPost("commit")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task> Commit(AmazonImportCommitRequestDto request) + { + var ownerId = this.GetUserId(); + var existingBooks = await FindAllBooksAsync(ownerId); + + var items = request.Items.Select(item => new AmazonBookImportRequestItem + { + Title = item.Title, + Year = item.Year, + Isbn = item.Isbn, + OwnedVersion = new OwnedVersionModel + { + CopyType = Enum.Parse(item.CopyType.ToString()), + Price = item.Price, + Vendor = item.Vendor, + AcquiredAt = item.AcquiredAt, + Reference = item.Reference + } + }).ToList(); + + var plan = AmazonBookImportMergeService.ComputeCommitPlan(ownerId, existingBooks, items); + + foreach (var book in plan.BooksToCreate) + { + await bookRepository.CreateAsync(book); + } + + foreach (var book in plan.BooksToUpdate) + { + await bookRepository.UpdateAsync(book.Id!, book, ownerId); + } + + return Ok(new AmazonImportCommitResultDto + { + BooksCreated = plan.BooksToCreate.Count, + BooksMergedInto = plan.BooksToUpdate.Count, + OwnedVersionsAdded = plan.OwnedVersionsAdded + }); + } + + private async Task> FindAllBooksAsync(string ownerId) => + (await bookRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty })).Items; +} diff --git a/src/WebApi/Mappers/AmazonOrderPreviewRowDtoMapper.cs b/src/WebApi/Mappers/AmazonOrderPreviewRowDtoMapper.cs new file mode 100644 index 00000000..7e058b0e --- /dev/null +++ b/src/WebApi/Mappers/AmazonOrderPreviewRowDtoMapper.cs @@ -0,0 +1,15 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto): is pure and +/// knows nothing about the web contract, so maps its rows +/// here - same shape as . +/// +[Mapper] +public partial class AmazonOrderPreviewRowDtoMapper +{ + public partial AmazonOrderPreviewRowDto ToDto(AmazonOrderPreviewRow model); +} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 6874aab8..c9727cf9 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -27,6 +27,7 @@ builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.PlaylistDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.VideoGameDtoMapper>(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs b/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs new file mode 100644 index 00000000..ac111ad9 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs @@ -0,0 +1,32 @@ +using System.Text; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Builds a small, synthetic Amazon order-history CSV for tests - never use a real personal export as a +/// test fixture (same rule as ). +/// +internal static class AmazonFixtureCsvBuilder +{ + public const string BookTitle = "Keeptrack Integration Test Book"; + + /// A real, checksum-valid ISBN-10 - exercises the "looks like a book" heuristic honestly. + public const string BookIsbn = "0552177571"; + + public const string BookOrderId = "999-1111111-1111111"; + + public const string NonBookTitle = "Keeptrack Integration Test Gadget"; + private const string NonBookAsin = "B000000001"; + private const string NonBookOrderId = "999-2222222-2222222"; + + public static byte[] Build() + { + var csv = $""" + ASIN,Order Date,Order ID,Product Name,Product Condition,Total Amount,Website + {BookIsbn},2024-01-24T09:01:58Z,{BookOrderId},{BookTitle},New,10.49,Amazon.fr + {NonBookAsin},2020-06-13T21:11:47Z,{NonBookOrderId},{NonBookTitle},New,14.99,Amazon.fr + + """; + return Encoding.UTF8.GetBytes(csv); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs new file mode 100644 index 00000000..3df76c27 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +public class AmazonImportResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + [Fact] + public async Task PreviewThenCommit_CreatesABookWithAnOwnedVersion_AndFlagsItAlreadyImportedOnReimport() + { + await Authenticate(); + + var csv = AmazonFixtureCsvBuilder.Build(); + + var preview = await PostFileAsync>("/api/import/amazon/preview", "file", csv, "orders.csv"); + var bookRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.BookTitle).Subject; + bookRow.LooksLikeBook.Should().BeTrue(); + bookRow.SuggestedIsbn.Should().Be(AmazonFixtureCsvBuilder.BookIsbn); + bookRow.AlreadyImported.Should().BeFalse(); + bookRow.OrderId.Should().Be(AmazonFixtureCsvBuilder.BookOrderId); + + var nonBookRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.NonBookTitle).Subject; + nonBookRow.LooksLikeBook.Should().BeFalse(); + + try + { + var commitRequest = new AmazonImportCommitRequestDto + { + Items = + [ + new AmazonImportCommitItemDto + { + RowId = bookRow.RowId, + Title = bookRow.Title, + Year = 1997, + Isbn = bookRow.SuggestedIsbn, + AcquiredAt = bookRow.OrderDate, + Price = bookRow.Price, + Vendor = bookRow.Vendor, + Reference = $"Amazon order {bookRow.OrderId}", + CopyType = CopyType.Physical + } + ] + }; + + var commitResult = await PostAsync("/api/import/amazon/commit", commitRequest); + commitResult.BooksCreated.Should().Be(1); + commitResult.BooksMergedInto.Should().Be(0); + commitResult.OwnedVersionsAdded.Should().Be(1); + + var books = await GetAsync>($"/api/books?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.BookTitle)}"); + var book = books.Items.Should().ContainSingle().Subject; + book.Year.Should().Be(1997); + book.Isbn.Should().Be(AmazonFixtureCsvBuilder.BookIsbn); + book.OwnedVersions.Should().ContainSingle(); + book.OwnedVersions[0].Price.Should().Be(10.49m); + book.OwnedVersions[0].Reference.Should().Contain(AmazonFixtureCsvBuilder.BookOrderId); + + // re-preview after commit: the just-imported order must now be flagged, so re-uploading a + // newer export later doesn't silently duplicate this book + var secondPreview = await PostFileAsync>("/api/import/amazon/preview", "file", csv, "orders.csv"); + secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.BookTitle && r.AlreadyImported); + } + finally + { + var books = await GetAsync>($"/api/books?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.BookTitle)}"); + foreach (var book in books.Items.Where(b => b.Id is not null)) + { + await DeleteAsync($"/api/books/{book.Id}"); + } + } + } +} diff --git a/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs new file mode 100644 index 00000000..fc81ea20 --- /dev/null +++ b/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class AmazonBookImportMergeServiceTest +{ + private const string OwnerId = "owner-1"; + + private static BookModel Book(string title, params OwnedVersionModel[] ownedVersions) => + new() { Id = title, OwnerId = OwnerId, Title = title, Author = string.Empty, OwnedVersions = [.. ownedVersions] }; + + private static AmazonBookImportRequestItem Item(string title, string? reference = null) => new() + { + Title = title, + OwnedVersion = new OwnedVersionModel { Reference = reference } + }; + + [Fact] + public void ComputeCommitPlan_CreatesANewBook_WhenNoExistingBookMatchesTheTitle() + { + var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [], [Item("The Secret")]); + + plan.BooksToCreate.Should().ContainSingle(); + plan.BooksToCreate[0].Title.Should().Be("The Secret"); + plan.BooksToCreate[0].Author.Should().Be(string.Empty); + plan.BooksToCreate[0].OwnedVersions.Should().ContainSingle(); + plan.BooksToUpdate.Should().BeEmpty(); + plan.OwnedVersionsAdded.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_MergesIntoAnExistingBook_WhenTheNormalizedTitleMatches() + { + var existing = Book(" Some Book "); + + var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [existing], [Item("some book")]); + + plan.BooksToCreate.Should().BeEmpty(); + plan.BooksToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); + existing.OwnedVersions.Should().ContainSingle(); + plan.OwnedVersionsAdded.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_MergesTwoSelectedRowsSharingATitle_IntoOneNewBook() + { + var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [], [Item("Duplicate Title"), Item("duplicate title")]); + + plan.BooksToCreate.Should().ContainSingle(); + plan.BooksToCreate[0].OwnedVersions.Should().HaveCount(2); + plan.BooksToUpdate.Should().BeEmpty(); + plan.OwnedVersionsAdded.Should().Be(2); + } + + [Fact] + public void FindImportedOrderIds_ReturnsOnlyOrderIdsFormattedByFormatOrderReference() + { + var existingBooks = new List + { + Book("Book A", new OwnedVersionModel { Reference = AmazonBookImportMergeService.FormatOrderReference("405-1111111-1111111") }), + Book("Book B", new OwnedVersionModel { Reference = "Bought at a flea market" }), + Book("Book C", new OwnedVersionModel { Reference = null }) + }; + + var result = AmazonBookImportMergeService.FindImportedOrderIds(existingBooks); + + result.Should().BeEquivalentTo(["405-1111111-1111111"]); + } +} diff --git a/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs new file mode 100644 index 00000000..8a335e18 --- /dev/null +++ b/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using AwesomeAssertions; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class AmazonOrderPreviewServiceTest +{ + private static Stream ToStream(string csv) => new MemoryStream(Encoding.UTF8.GetBytes(csv)); + + // The "é" sequence below is deliberately literal (not a copy/paste accident) - it reproduces the real + // export's mojibake byte-for-byte, confirmed against the raw bytes of a real Amazon order-history CSV. + private const string Csv = """ + ASIN,Order Date,Order ID,Product Name,Product Condition,Total Amount,Website + 0552177571,2024-01-24T09:01:58Z,405-2296545-4493925,"protection d'écrans",New,10.49,Amazon.fr + B002KMW6ZI,2010-12-06T09:02:46Z,402-4436663-3305145,Some Gadget,Neuf,'19.16',Amazon.fr + """; + + [Fact] + public void BuildPreview_RepairsMojibakeInTheProductName() + { + var result = AmazonOrderPreviewService.BuildPreview(ToStream(Csv), new HashSet()); + + result[0].Title.Should().Be("protection d'écrans"); + } + + [Fact] + public void BuildPreview_DecodesARawNumericHtmlEntityInTheProductName() + { + const string csv = """ + ASIN,Order Date,Order ID,Product Name,Product Condition,Total Amount,Website + 2811205063,2020-02-02T09:26:31Z,403-0337751-9465130,"Sorceleur, Tome 1: Le Dernier Vœu",New,7.1,Amazon.fr + """; + + var result = AmazonOrderPreviewService.BuildPreview(ToStream(csv), new HashSet()); + + result[0].Title.Should().Be("Sorceleur, Tome 1: Le Dernier Vœu"); + } + + [Fact] + public void BuildPreview_FlagsAnIsbn10ShapedAsinAsLikelyABook() + { + var result = AmazonOrderPreviewService.BuildPreview(ToStream(Csv), new HashSet()); + + result[0].LooksLikeBook.Should().BeTrue(); + result[0].SuggestedIsbn.Should().Be("0552177571"); + } + + [Fact] + public void BuildPreview_DoesNotFlagANonIsbnAsin() + { + var result = AmazonOrderPreviewService.BuildPreview(ToStream(Csv), new HashSet()); + + result[1].LooksLikeBook.Should().BeFalse(); + result[1].SuggestedIsbn.Should().BeNull(); + } + + [Fact] + public void BuildPreview_AcceptsAnXCheckDigitIsbn10() + { + const string csv = """ + ASIN,Order Date,Order ID,Product Name,Product Condition,Total Amount,Website + 080442957X,2020-01-01T00:00:00Z,111-1111111-1111111,A Book,New,9.99,Amazon.fr + """; + + var result = AmazonOrderPreviewService.BuildPreview(ToStream(csv), new HashSet()); + + result[0].LooksLikeBook.Should().BeTrue(); + } + + [Fact] + public void BuildPreview_StripsAmazonsExcelFormulaInjectionApostropheBeforeParsingThePrice() + { + var result = AmazonOrderPreviewService.BuildPreview(ToStream(Csv), new HashSet()); + + result[1].Price.Should().Be(19.16m); + } + + [Fact] + public void BuildPreview_FlagsARowWhoseOrderIdIsAlreadyImported() + { + var alreadyImported = new HashSet { "405-2296545-4493925" }; + + var result = AmazonOrderPreviewService.BuildPreview(ToStream(Csv), alreadyImported); + + result[0].AlreadyImported.Should().BeTrue(); + result[1].AlreadyImported.Should().BeFalse(); + } +} From b497232f250fab0eac45c5e34a19d746544ff406 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 18:40:45 +0200 Subject: [PATCH 03/35] Add notes in books --- .../Components/Import/AmazonImportPage.razor | 4 +++ .../Models/AmazonBookImportRequestItem.cs | 7 ++++ .../Services/AmazonBookImportMergeService.cs | 16 +++++++++ .../Dto/AmazonImportCommitItemDto.cs | 7 ++++ .../Controllers/AmazonImportController.cs | 1 + .../Resources/AmazonImportResourceTest.cs | 2 ++ .../AmazonBookImportMergeServiceTest.cs | 36 ++++++++++++++++++- 7 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/BlazorApp/Components/Import/AmazonImportPage.razor b/src/BlazorApp/Components/Import/AmazonImportPage.razor index 60140dff..dd923f3c 100644 --- a/src/BlazorApp/Components/Import/AmazonImportPage.razor +++ b/src/BlazorApp/Components/Import/AmazonImportPage.razor @@ -182,6 +182,10 @@ { RowId = row.Preview.RowId, Title = row.Title, + // The original, unedited title from the parsed preview - kept separate from the editable + // row.Title above so it can be recorded verbatim in the created book's notes, regardless of + // whatever the user typed over it here. + AmazonTitle = row.Preview.Title, // Amazon's export has no publication year (only the purchase date), so it's never pre-filled - // but worth entering by hand here if known, since it sharpens the deferred reference-lookup // pass's match precision later (an ambiguous multi-candidate title+year search is left diff --git a/src/Domain/Models/AmazonBookImportRequestItem.cs b/src/Domain/Models/AmazonBookImportRequestItem.cs index 6e838dfc..9e5fc283 100644 --- a/src/Domain/Models/AmazonBookImportRequestItem.cs +++ b/src/Domain/Models/AmazonBookImportRequestItem.cs @@ -8,6 +8,13 @@ public class AmazonBookImportRequestItem { public required string Title { get; set; } + /// + /// The title exactly as Amazon listed it (before any edit the user made in the review UI) - preserved + /// in 's notes for a newly-created + /// book, since reference-data linking is expected to overwrite later. + /// + public required string AmazonTitle { get; set; } + public int? Year { get; set; } public string? Isbn { get; set; } diff --git a/src/Domain/Services/AmazonBookImportMergeService.cs b/src/Domain/Services/AmazonBookImportMergeService.cs index 96d44f4e..b2b55349 100644 --- a/src/Domain/Services/AmazonBookImportMergeService.cs +++ b/src/Domain/Services/AmazonBookImportMergeService.cs @@ -73,6 +73,7 @@ public static AmazonBookImportPlan ComputeCommitPlan(string ownerId, IReadOnlyCo Author = string.Empty, Year = item.Year, Isbn = item.Isbn, + Notes = BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), OwnedVersions = [item.OwnedVersion] }; plan.BooksToCreate.Add(book); @@ -88,4 +89,19 @@ public static AmazonBookImportPlan ComputeCommitPlan(string ownerId, IReadOnlyCo return plan; } + + /// + /// Reference-data linking is expected to overwrite / + /// with the provider's canonical values, and the user may have already cleaned up item.Title + /// before commit - so this is the one place Amazon's own original listing text is preserved, for a book + /// created by this import. Only set at creation time (see the "book merges into an existing one" branch + /// above, which never touches ): a pre-existing book's provenance isn't + /// this import's to invent. + /// + private static string BuildAmazonProvenanceNotes(string amazonTitle, string? isbn) + { + var lines = new List { $"Title from Amazon: {amazonTitle}" }; + if (isbn is not null) lines.Add($"ISBN from Amazon: {isbn}"); + return string.Join('\n', lines); + } } diff --git a/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs b/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs index ec277e3f..93403009 100644 --- a/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs +++ b/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs @@ -15,6 +15,13 @@ public class AmazonImportCommitItemDto public required string Title { get; set; } + /// + /// The title exactly as Amazon listed it, even if was edited in the review UI - + /// recorded in the created book's notes, since reference-data linking is expected to overwrite + /// later. + /// + public required string AmazonTitle { get; set; } + /// /// Publication year, if the user happens to know it - Amazon's export has no source for this (order /// date is the purchase year, not the book's), so it's never auto-filled. diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs index b08d582a..e6dbb91f 100644 --- a/src/WebApi/Controllers/AmazonImportController.cs +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -66,6 +66,7 @@ public async Task> Commit(AmazonImport var items = request.Items.Select(item => new AmazonBookImportRequestItem { Title = item.Title, + AmazonTitle = item.AmazonTitle, Year = item.Year, Isbn = item.Isbn, OwnedVersion = new OwnedVersionModel diff --git a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs index 3df76c27..73bde271 100644 --- a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs @@ -40,6 +40,7 @@ public async Task PreviewThenCommit_CreatesABookWithAnOwnedVersion_AndFlagsItAlr { RowId = bookRow.RowId, Title = bookRow.Title, + AmazonTitle = bookRow.Title, Year = 1997, Isbn = bookRow.SuggestedIsbn, AcquiredAt = bookRow.OrderDate, @@ -63,6 +64,7 @@ public async Task PreviewThenCommit_CreatesABookWithAnOwnedVersion_AndFlagsItAlr book.OwnedVersions.Should().ContainSingle(); book.OwnedVersions[0].Price.Should().Be(10.49m); book.OwnedVersions[0].Reference.Should().Contain(AmazonFixtureCsvBuilder.BookOrderId); + book.Notes.Should().Be($"Title from Amazon: {AmazonFixtureCsvBuilder.BookTitle}\nISBN from Amazon: {AmazonFixtureCsvBuilder.BookIsbn}"); // re-preview after commit: the just-imported order must now be flagged, so re-uploading a // newer export later doesn't silently duplicate this book diff --git a/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs index fc81ea20..37f536c2 100644 --- a/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs +++ b/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs @@ -14,9 +14,11 @@ public class AmazonBookImportMergeServiceTest private static BookModel Book(string title, params OwnedVersionModel[] ownedVersions) => new() { Id = title, OwnerId = OwnerId, Title = title, Author = string.Empty, OwnedVersions = [.. ownedVersions] }; - private static AmazonBookImportRequestItem Item(string title, string? reference = null) => new() + private static AmazonBookImportRequestItem Item(string title, string? reference = null, string? amazonTitle = null, string? isbn = null) => new() { Title = title, + AmazonTitle = amazonTitle ?? title, + Isbn = isbn, OwnedVersion = new OwnedVersionModel { Reference = reference } }; @@ -33,6 +35,38 @@ public void ComputeCommitPlan_CreatesANewBook_WhenNoExistingBookMatchesTheTitle( plan.OwnedVersionsAdded.Should().Be(1); } + [Fact] + public void ComputeCommitPlan_RecordsAmazonsOriginalTitleAndIsbnInNotes_ForANewlyCreatedBook() + { + var item = Item("The Secret", amazonTitle: "The Secret: Jack Reacher, Book 28", isbn: "0552177571"); + + var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [], [item]); + + plan.BooksToCreate[0].Notes.Should().Be("Title from Amazon: The Secret: Jack Reacher, Book 28\nISBN from Amazon: 0552177571"); + } + + [Fact] + public void ComputeCommitPlan_OmitsTheIsbnNoteLine_WhenThereIsNoIsbn() + { + var item = Item("A Book With No Isbn", amazonTitle: "A Book With No Isbn"); + + var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [], [item]); + + plan.BooksToCreate[0].Notes.Should().Be("Title from Amazon: A Book With No Isbn"); + } + + [Fact] + public void ComputeCommitPlan_DoesNotTouchNotes_WhenMergingIntoAnExistingBook() + { + var existing = Book("Some Book"); + existing.Notes = "My own pre-existing notes"; + + var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [existing], [Item("some book", amazonTitle: "Some Book (Amazon listing)")]); + + plan.BooksToCreate.Should().BeEmpty(); + existing.Notes.Should().Be("My own pre-existing notes"); + } + [Fact] public void ComputeCommitPlan_MergesIntoAnExistingBook_WhenTheNormalizedTitleMatches() { From a935300495d8fbfb3ed6058a20482b99b52048ef Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 18:58:53 +0200 Subject: [PATCH 04/35] Add other types from amazon order file --- .../Components/Import/AmazonImportPage.razor | 85 +++++-- src/Domain/Models/AmazonBookImportPlan.cs | 17 -- .../Models/AmazonBookImportRequestItem.cs | 23 -- src/Domain/Models/AmazonImportPlan.cs | 21 ++ .../AmazonOwnedItemImportRequestItem.cs | 26 +++ .../AmazonVideoGameImportRequestItem.cs | 18 ++ .../Services/AmazonBookImportMergeService.cs | 107 --------- .../Services/AmazonImportMergeService.cs | 144 ++++++++++++ .../Dto/AmazonImportCommitItemDto.cs | 22 +- .../Dto/AmazonImportCommitResultDto.cs | 44 +++- .../Dto/AmazonImportMediaType.cs | 13 ++ .../Controllers/AmazonImportController.cs | 218 +++++++++++++++--- .../Resources/AmazonFixtureCsvBuilder.cs | 28 ++- .../Resources/AmazonImportResourceTest.cs | 116 ++++++++-- .../AmazonBookImportMergeServiceTest.cs | 108 --------- .../Services/AmazonImportMergeServiceTest.cs | 211 +++++++++++++++++ 16 files changed, 858 insertions(+), 343 deletions(-) delete mode 100644 src/Domain/Models/AmazonBookImportPlan.cs delete mode 100644 src/Domain/Models/AmazonBookImportRequestItem.cs create mode 100644 src/Domain/Models/AmazonImportPlan.cs create mode 100644 src/Domain/Models/AmazonOwnedItemImportRequestItem.cs create mode 100644 src/Domain/Models/AmazonVideoGameImportRequestItem.cs delete mode 100644 src/Domain/Services/AmazonBookImportMergeService.cs create mode 100644 src/Domain/Services/AmazonImportMergeService.cs create mode 100644 src/WebApi.Contracts/Dto/AmazonImportMediaType.cs delete mode 100644 test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs create mode 100644 test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs diff --git a/src/BlazorApp/Components/Import/AmazonImportPage.razor b/src/BlazorApp/Components/Import/AmazonImportPage.razor index dd923f3c..9c80189f 100644 --- a/src/BlazorApp/Components/Import/AmazonImportPage.razor +++ b/src/BlazorApp/Components/Import/AmazonImportPage.razor @@ -1,6 +1,7 @@ @page "/import/amazon" @attribute [Authorize] @using Keeptrack.WebApi.Contracts.Dto +@using Keeptrack.BlazorApp.Components.Inventory.Pages

Import from Amazon

@@ -9,7 +10,7 @@

Upload the order-history CSV from Amazon's "Request My Data" export. - Amazon's export has no category column, so every row is shown for you to review - only books are supported so far. + Amazon's export has no category column, so every row is shown for you to review and pick a type for. Nothing is imported until you click "Import selected" below.

@@ -34,7 +35,10 @@
@DisplayedRows.Count() row(s) shown
- +
+ + +
@@ -46,10 +50,10 @@ Title Year + Type Copy + Platform Order info - Vendor - Reference @@ -60,17 +64,42 @@ @if (row.Preview.AlreadyImported) { - already imported + } + + + + + @if (row.MediaType == AmazonImportMediaType.VideoGame) + { + + } + else + { + + } + @if (row.Isbn is not null) { @@ -85,15 +114,18 @@
@row.Price.Value.ToString("0.00") €
} - @(row.Vendor ?? "—") - @(row.Reference ?? "—") }
-
@@ -107,9 +139,10 @@ @if (_commitResult is not null) {
-

Books created: @_commitResult.BooksCreated

-

Existing books updated with a new copy: @_commitResult.BooksMergedInto

-

Owned versions added: @_commitResult.OwnedVersionsAdded

+

Books: @_commitResult.BooksCreated created, @_commitResult.BooksMergedInto merged into existing, @_commitResult.BooksSkipped already imported

+

Movies: @_commitResult.MoviesCreated created, @_commitResult.MoviesMergedInto merged into existing, @_commitResult.MoviesSkipped already imported

+

TV shows: @_commitResult.TvShowsCreated created, @_commitResult.TvShowsMergedInto merged into existing, @_commitResult.TvShowsSkipped already imported

+

Video games: @_commitResult.VideoGamesCreated created, @_commitResult.VideoGamesMergedInto merged into existing, @_commitResult.VideoGamesSkipped already imported

} @@ -123,11 +156,18 @@ private List? _rows; private bool _showAll; + /// Defaults on - a re-uploaded export is mostly already-imported rows, and hiding them by + /// default is what actually keeps the review list short; "Show all order items" above is a separate, + /// orthogonal filter (books vs. everything). + private bool _hideAlreadyImported = true; + private bool _committing; private string? _commitError; private AmazonImportCommitResultDto? _commitResult; - private IEnumerable DisplayedRows => _rows is null ? [] : _showAll ? _rows : _rows.Where(r => r.Preview.LooksLikeBook); + private IEnumerable DisplayedRows => (_rows ?? []) + .Where(r => _showAll || r.Preview.LooksLikeBook) + .Where(r => !_hideAlreadyImported || !r.Preview.AlreadyImported); private IEnumerable SelectedRows => _rows is null ? [] : _rows.Where(r => r.Selected); @@ -183,15 +223,18 @@ RowId = row.Preview.RowId, Title = row.Title, // The original, unedited title from the parsed preview - kept separate from the editable - // row.Title above so it can be recorded verbatim in the created book's notes, regardless of + // row.Title above so it can be recorded verbatim in the created item's notes, regardless of // whatever the user typed over it here. AmazonTitle = row.Preview.Title, + MediaType = row.MediaType, // Amazon's export has no publication year (only the purchase date), so it's never pre-filled - // but worth entering by hand here if known, since it sharpens the deferred reference-lookup // pass's match precision later (an ambiguous multi-candidate title+year search is left // unresolved for manual review rather than guessed, same as everywhere else in the app). Year = row.Year, Isbn = row.Isbn, + // Only meaningful (and only validated server-side) for a VideoGame row. + Platform = row.MediaType == AmazonImportMediaType.VideoGame ? row.Platform : null, AcquiredAt = row.AcquiredAt, Price = row.Price, Vendor = row.Vendor, @@ -215,13 +258,16 @@ { Preview = preview, Selected = preview.LooksLikeBook && !preview.AlreadyImported, + // Empty (forcing an explicit choice) unless the ISBN heuristic suggested a book - there's no + // equivalent signal for the other three types, so guessing one of them would be worse than asking. + MediaType = preview.LooksLikeBook ? AmazonImportMediaType.Book : null, Title = preview.Title, Isbn = preview.SuggestedIsbn, AcquiredAt = preview.OrderDate, Price = preview.Price, Vendor = preview.Vendor, - // Mirrors AmazonBookImportMergeService.FormatOrderReference's format server-side - this is just - // the review UI's own display/commit value, not import logic, so it stays a plain string here. + // Mirrors AmazonImportMergeService.FormatOrderReference's format server-side - this is just the + // review UI's own display/commit value, not import logic, so it stays a plain string here. Reference = $"Amazon order {preview.OrderId}" }; @@ -236,6 +282,12 @@ /// Never pre-filled (Amazon's export has no publication year) - worth entering by hand if known, see . public int? Year { get; set; } + /// + /// Null (forcing an explicit pick, see 's sibling commit-button + /// guard) unless the ISBN heuristic suggested Book - see . + /// + public AmazonImportMediaType? MediaType { get; set; } + public string? Isbn { get; set; } public DateOnly? AcquiredAt { get; set; } public decimal? Price { get; set; } @@ -244,5 +296,8 @@ /// The other field worth a decision - Amazon's export can't reliably tell physical from digital. public CopyType CopyType { get; set; } + + /// PS5/Xbox/PC/Switch... - only relevant (and only sent) when is VideoGame. + public string? Platform { get; set; } } } diff --git a/src/Domain/Models/AmazonBookImportPlan.cs b/src/Domain/Models/AmazonBookImportPlan.cs deleted file mode 100644 index 87b32a21..00000000 --- a/src/Domain/Models/AmazonBookImportPlan.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; - -namespace Keeptrack.Domain.Models; - -/// -/// What decided: brand new books to -/// create, and existing (or already-created-this-batch) books to update - each already carrying the extra -/// appended to its . -/// -public class AmazonBookImportPlan -{ - public List BooksToCreate { get; set; } = []; - - public List BooksToUpdate { get; set; } = []; - - public int OwnedVersionsAdded { get; set; } -} diff --git a/src/Domain/Models/AmazonBookImportRequestItem.cs b/src/Domain/Models/AmazonBookImportRequestItem.cs deleted file mode 100644 index 9e5fc283..00000000 --- a/src/Domain/Models/AmazonBookImportRequestItem.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Keeptrack.Domain.Models; - -/// -/// One user-selected/edited row from the review UI, already translated from the web contract - the input -/// to . -/// -public class AmazonBookImportRequestItem -{ - public required string Title { get; set; } - - /// - /// The title exactly as Amazon listed it (before any edit the user made in the review UI) - preserved - /// in 's notes for a newly-created - /// book, since reference-data linking is expected to overwrite later. - /// - public required string AmazonTitle { get; set; } - - public int? Year { get; set; } - - public string? Isbn { get; set; } - - public required OwnedVersionModel OwnedVersion { get; set; } -} diff --git a/src/Domain/Models/AmazonImportPlan.cs b/src/Domain/Models/AmazonImportPlan.cs new file mode 100644 index 00000000..c3ca0c86 --- /dev/null +++ b/src/Domain/Models/AmazonImportPlan.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +/// +/// What decided: +/// brand new items to create, and existing (or already-created-this-batch) items to update - each already +/// carrying the extra owned copy appended (an or a +/// , depending on ). +/// +public class AmazonImportPlan +{ + public List ItemsToCreate { get; set; } = []; + + public List ItemsToUpdate { get; set; } = []; + + public int OwnedCopiesAdded { get; set; } + + /// A row whose order reference already matched an existing owned copy - not duplicated. + public int OwnedCopiesSkipped { get; set; } +} diff --git a/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs b/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs new file mode 100644 index 00000000..4c4db97a --- /dev/null +++ b/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs @@ -0,0 +1,26 @@ +namespace Keeptrack.Domain.Models; + +/// +/// One user-selected/edited row from the review UI, already translated from the web contract - the input +/// to for the three +/// domains that use (Book, Movie, TvShow). See +/// for VideoGame's own shape. +/// +public class AmazonOwnedItemImportRequestItem +{ + public required string Title { get; set; } + + /// + /// The title exactly as Amazon listed it (before any edit the user made in the review UI) - preserved + /// in the created item's notes, since reference-data linking is expected to overwrite + /// later. + /// + public required string AmazonTitle { get; set; } + + public int? Year { get; set; } + + /// Book-only (null for Movie/TvShow) - see . + public string? Isbn { get; set; } + + public required OwnedVersionModel OwnedVersion { get; set; } +} diff --git a/src/Domain/Models/AmazonVideoGameImportRequestItem.cs b/src/Domain/Models/AmazonVideoGameImportRequestItem.cs new file mode 100644 index 00000000..8028c5cb --- /dev/null +++ b/src/Domain/Models/AmazonVideoGameImportRequestItem.cs @@ -0,0 +1,18 @@ +namespace Keeptrack.Domain.Models; + +/// +/// VideoGame's own shape for - +/// has no concept, using +/// (with a required platform name Amazon's export can never supply) +/// instead. See for Book/Movie/TvShow's shared shape. +/// +public class AmazonVideoGameImportRequestItem +{ + public required string Title { get; set; } + + public required string AmazonTitle { get; set; } + + public int? Year { get; set; } + + public required VideoGamePlatformModel Platform { get; set; } +} diff --git a/src/Domain/Services/AmazonBookImportMergeService.cs b/src/Domain/Services/AmazonBookImportMergeService.cs deleted file mode 100644 index b2b55349..00000000 --- a/src/Domain/Services/AmazonBookImportMergeService.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Keeptrack.Common.System; -using Keeptrack.Domain.Models; - -namespace Keeptrack.Domain.Services; - -/// -/// Computes what to create/update when committing a set of user-reviewed Amazon order rows as books. Pure: -/// takes the owner's already-fetched books and the selected rows, returns a plan - all repository access -/// stays in AmazonImportController. Matching is by normalized title (, -/// the same "same title" rule the TV Time import already established), merging within the same commit -/// batch too: two selected rows sharing a title become one book with two owned versions, even if neither -/// existed before this commit. -/// -public static class AmazonBookImportMergeService -{ - private const string OrderReferencePrefix = "Amazon order "; - - /// - /// The one place that formats an owned version's for an - /// imported order - human-readable, and also the dedup key looks for - /// on a later re-import. - /// - public static string FormatOrderReference(string orderId) => OrderReferencePrefix + orderId; - - /// - /// Order ids already referenced by an existing owned version - used at preview time to flag rows that - /// look like they were imported before, so re-uploading a newer export doesn't duplicate them. - /// - public static HashSet FindImportedOrderIds(IEnumerable existingBooks) => - existingBooks - .SelectMany(book => book.OwnedVersions) - .Select(version => version.Reference) - .Where(reference => reference is not null && reference.StartsWith(OrderReferencePrefix, StringComparison.Ordinal)) - .Select(reference => reference![OrderReferencePrefix.Length..]) - .ToHashSet(); - - public static AmazonBookImportPlan ComputeCommitPlan(string ownerId, IReadOnlyCollection existingBooks, IReadOnlyList items) - { - var plan = new AmazonBookImportPlan(); - - var byNormalizedTitle = new Dictionary(); - foreach (var book in existingBooks) - { - byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(book.Title), book); - } - - // Books created earlier in this same batch are tracked separately from pre-existing ones: a later - // row matching one must only get its owned version appended (already reflected via BooksToCreate), - // never also queued onto BooksToUpdate - it has no Id yet, so "updating" it would be meaningless. - var createdThisBatch = new HashSet(); - - foreach (var item in items) - { - var key = TitleNormalizer.Normalize(item.Title); - - if (byNormalizedTitle.TryGetValue(key, out var existing)) - { - existing.OwnedVersions.Add(item.OwnedVersion); - if (!createdThisBatch.Contains(existing) && !plan.BooksToUpdate.Contains(existing)) - { - plan.BooksToUpdate.Add(existing); - } - } - else - { - var book = new BookModel - { - OwnerId = ownerId, - Title = item.Title, - Author = string.Empty, - Year = item.Year, - Isbn = item.Isbn, - Notes = BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), - OwnedVersions = [item.OwnedVersion] - }; - plan.BooksToCreate.Add(book); - createdThisBatch.Add(book); - - // so a later row in the same batch sharing this title merges into it too, instead of - // creating a second book - byNormalizedTitle[key] = book; - } - - plan.OwnedVersionsAdded++; - } - - return plan; - } - - /// - /// Reference-data linking is expected to overwrite / - /// with the provider's canonical values, and the user may have already cleaned up item.Title - /// before commit - so this is the one place Amazon's own original listing text is preserved, for a book - /// created by this import. Only set at creation time (see the "book merges into an existing one" branch - /// above, which never touches ): a pre-existing book's provenance isn't - /// this import's to invent. - /// - private static string BuildAmazonProvenanceNotes(string amazonTitle, string? isbn) - { - var lines = new List { $"Title from Amazon: {amazonTitle}" }; - if (isbn is not null) lines.Add($"ISBN from Amazon: {isbn}"); - return string.Join('\n', lines); - } -} diff --git a/src/Domain/Services/AmazonImportMergeService.cs b/src/Domain/Services/AmazonImportMergeService.cs new file mode 100644 index 00000000..fbaeea09 --- /dev/null +++ b/src/Domain/Services/AmazonImportMergeService.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Computes what to create/update when committing a set of user-reviewed Amazon order rows, for any +/// trackable item type. Pure: takes the owner's already-fetched items and the selected rows, returns a plan - +/// all repository access stays in AmazonImportController. Matching is by normalized title +/// (, the same "same title" rule the TV Time import already established), +/// merging within the same commit batch too: two selected rows sharing a title become one item with two +/// owned copies, even if neither existed before this commit. +/// +/// Generic over both the tracked model (BookModel/MovieModel/TvShowModel/VideoGameModel) +/// and its own request-item shape (AmazonOwnedItemImportRequestItem for the first three, +/// AmazonVideoGameImportRequestItem for VideoGame) via delegates rather than a shared interface - +/// Book/Movie/TvShow's OwnedVersions and VideoGame's differently-shaped Platforms both flow +/// through the exact same algorithm this way, with no changes to any of those models. +/// +public static class AmazonImportMergeService +{ + private const string OrderReferencePrefix = "Amazon order "; + + /// + /// The one place that formats an owned copy's Reference for an imported order - human-readable, + /// and also the dedup key looks for on a later re-import. + /// + public static string FormatOrderReference(string orderId) => OrderReferencePrefix + orderId; + + /// + /// Order ids already referenced by an existing owned copy - used at preview time to flag rows that look + /// like they were imported before, so re-uploading a newer export doesn't duplicate them. + /// reads whichever collection carries the owned copies for + /// (OwnedVersions or Platforms). + /// + public static HashSet FindImportedOrderIds(IEnumerable existingItems, Func> getReferences) => + existingItems + .SelectMany(getReferences) + .Where(reference => reference is not null && reference.StartsWith(OrderReferencePrefix, StringComparison.Ordinal)) + .Select(reference => reference![OrderReferencePrefix.Length..]) + .ToHashSet(); + + /// + /// / make the order + /// reference (see ) the *primary* dedup key, not just an advisory + /// preview-time flag: a re-committed row whose reference already exists on some existing item - under + /// any title, even one reference-data linking has since renamed - is skipped outright rather than + /// falling through to title matching. Two real bugs motivated this: (1) re-running the same commit + /// created a second owned copy for the same order every time, because title-matching alone doesn't + /// know "this exact copy is already here"; (2) once an item's title changed after a first import, + /// title-matching a re-import of the same order no longer found it at all and created a brand new + /// duplicate item instead. Title matching is still the fallback for a genuinely new order of an item + /// already owned under a different order (a second copy bought separately). + /// + public static AmazonImportPlan ComputeCommitPlan( + IReadOnlyCollection existingItems, + IReadOnlyList items, + Func getExistingTitle, + Func> getExistingReferences, + Func getItemTitle, + Func getItemReference, + Func createNew, + Action appendOwnedCopy) + where TModel : class + { + var plan = new AmazonImportPlan(); + + var byNormalizedTitle = new Dictionary(); + var byReference = new Dictionary(); + foreach (var existing in existingItems) + { + byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(getExistingTitle(existing)), existing); + foreach (var reference in getExistingReferences(existing)) + { + if (reference is not null) byReference.TryAdd(reference, existing); + } + } + + // Items created earlier in this same batch are tracked separately from pre-existing ones: a later + // row matching one must only get its owned copy appended (already reflected via ItemsToCreate), + // never also queued onto ItemsToUpdate - it has no Id yet, so "updating" it would be meaningless. + var createdThisBatch = new HashSet(); + + foreach (var item in items) + { + var reference = getItemReference(item); + if (reference is not null && byReference.ContainsKey(reference)) + { + plan.OwnedCopiesSkipped++; + continue; + } + + var key = TitleNormalizer.Normalize(getItemTitle(item)); + TModel target; + + if (byNormalizedTitle.TryGetValue(key, out var existing)) + { + appendOwnedCopy(existing, item); + if (!createdThisBatch.Contains(existing) && !plan.ItemsToUpdate.Contains(existing)) + { + plan.ItemsToUpdate.Add(existing); + } + + target = existing; + } + else + { + var created = createNew(item); + plan.ItemsToCreate.Add(created); + createdThisBatch.Add(created); + + // so a later row in the same batch sharing this title merges into it too, instead of + // creating a second item + byNormalizedTitle[key] = created; + target = created; + } + + // registers this reference immediately (not just from the initial existingItems scan), so a + // second row in the same batch carrying the same reference is caught by the check above too + if (reference is not null) byReference[reference] = target; + + plan.OwnedCopiesAdded++; + } + + return plan; + } + + /// + /// Reference-data linking is expected to overwrite the created item's title (and, for a book, its ISBN) + /// with the provider's canonical values, and the user may have already cleaned up the title before + /// commit - so this is the one place Amazon's own original listing text is preserved, for an item + /// created by this import. Only used at creation time: a pre-existing item's provenance isn't this + /// import's to invent. is null for every domain but Book. + /// + public static string BuildAmazonProvenanceNotes(string amazonTitle, string? isbn) + { + var lines = new List { $"Title from Amazon: {amazonTitle}" }; + if (isbn is not null) lines.Add($"ISBN from Amazon: {isbn}"); + return string.Join('\n', lines); + } +} diff --git a/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs b/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs index 93403009..bb769253 100644 --- a/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs +++ b/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs @@ -17,19 +17,35 @@ public class AmazonImportCommitItemDto /// /// The title exactly as Amazon listed it, even if was edited in the review UI - - /// recorded in the created book's notes, since reference-data linking is expected to overwrite + /// recorded in the created item's notes, since reference-data linking is expected to overwrite /// later. /// public required string AmazonTitle { get; set; } /// - /// Publication year, if the user happens to know it - Amazon's export has no source for this (order - /// date is the purchase year, not the book's), so it's never auto-filled. + /// Which trackable item type to create/merge this row as. Nullable (and validated as required + /// server-side, same as for a video game) rather than defaulting to + /// , so a row the "looks like a book" heuristic didn't suggest + /// can't be silently committed as a book just because the reviewer forgot to pick a type. + /// + public AmazonImportMediaType? MediaType { get; set; } + + /// + /// Publication/release year, if the user happens to know it - Amazon's export has no source for this + /// (order date is the purchase year, not the item's), so it's never auto-filled. /// public int? Year { get; set; } + /// Book-only - ignored for every other . public string? Isbn { get; set; } + /// + /// VideoGame-only (PS5/Xbox/PC/Switch...) - required and validated server-side when + /// is , ignored otherwise. + /// Amazon's export has no signal for this at all. + /// + public string? Platform { get; set; } + public DateOnly? AcquiredAt { get; set; } public decimal? Price { get; set; } diff --git a/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs b/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs index 41fb07ab..eeb94151 100644 --- a/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs +++ b/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs @@ -1,22 +1,44 @@ namespace Keeptrack.WebApi.Contracts.Dto; /// -/// Outcome of committing a selected set of Amazon order rows as books. +/// Outcome of committing a selected set of Amazon order rows, broken down per trackable item type - only +/// the types actually present in the commit request end up non-zero. /// public class AmazonImportCommitResultDto { - /// - /// Brand new books created. - /// + /// Brand new books created. public int BooksCreated { get; set; } - /// - /// Existing books (including ones created earlier in this same commit) that received an additional owned version. - /// + /// Existing books (including ones created earlier in this same commit) that received an additional owned version. public int BooksMergedInto { get; set; } - /// - /// Total owned versions added across both created and merged-into books. - /// - public int OwnedVersionsAdded { get; set; } + /// Rows whose order reference already matched an existing owned version - not duplicated. + public int BooksSkipped { get; set; } + + /// Brand new movies created. + public int MoviesCreated { get; set; } + + /// Existing movies (including ones created earlier in this same commit) that received an additional owned version. + public int MoviesMergedInto { get; set; } + + /// Rows whose order reference already matched an existing owned version - not duplicated. + public int MoviesSkipped { get; set; } + + /// Brand new TV shows created. + public int TvShowsCreated { get; set; } + + /// Existing TV shows (including ones created earlier in this same commit) that received an additional owned version. + public int TvShowsMergedInto { get; set; } + + /// Rows whose order reference already matched an existing owned version - not duplicated. + public int TvShowsSkipped { get; set; } + + /// Brand new video games created. + public int VideoGamesCreated { get; set; } + + /// Existing video games (including ones created earlier in this same commit) that received an additional platform entry. + public int VideoGamesMergedInto { get; set; } + + /// Rows whose order reference already matched an existing platform entry - not duplicated. + public int VideoGamesSkipped { get; set; } } diff --git a/src/WebApi.Contracts/Dto/AmazonImportMediaType.cs b/src/WebApi.Contracts/Dto/AmazonImportMediaType.cs new file mode 100644 index 00000000..c7c557a4 --- /dev/null +++ b/src/WebApi.Contracts/Dto/AmazonImportMediaType.cs @@ -0,0 +1,13 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Which trackable item type an Amazon order-history row should be imported as - picked per row in the +/// review UI, since Amazon's export has no category column to detect this automatically. +/// +public enum AmazonImportMediaType +{ + Book, + Movie, + TvShow, + VideoGame +} diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs index e6dbb91f..41bb9fc9 100644 --- a/src/WebApi/Controllers/AmazonImportController.cs +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Keeptrack.Common.System; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Domain.Services; @@ -10,14 +11,19 @@ namespace Keeptrack.WebApi.Controllers; /// /// Previews an Amazon.fr order-history export and commits the rows the user selected/edited in the review -/// UI as books (the only supported target for now - see ). +/// UI as books, movies, TV shows, or video games (picked per row - see ). /// Synchronous on both ends: unlike the TV Time import, there is no external API call in the loop, so even /// a multi-year export completes well within a normal request. /// [ApiController] [Authorize(Policy = "MemberOnly")] [Route("api/import/amazon")] -public class AmazonImportController(IBookRepository bookRepository, AmazonOrderPreviewRowDtoMapper previewMapper) : ControllerBase +public class AmazonImportController( + IBookRepository bookRepository, + IMovieRepository movieRepository, + ITvShowRepository tvShowRepository, + IVideoGameRepository videoGameRepository, + AmazonOrderPreviewRowDtoMapper previewMapper) : ControllerBase { /// /// Parses the uploaded order-history CSV and returns every line item for review - nothing is persisted @@ -40,8 +46,19 @@ public async Task>> Preview(IFormFil } var ownerId = this.GetUserId(); - var existingBooks = await FindAllBooksAsync(ownerId); - var alreadyImportedOrderIds = AmazonBookImportMergeService.FindImportedOrderIds(existingBooks); + + // "Already imported" must be checked across every type, not just books - a row previously imported + // as a movie must still be flagged when the same export is uploaded again. + var existingBooks = await FindAllAsync(bookRepository, ownerId, new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty }); + var existingMovies = await FindAllAsync(movieRepository, ownerId, new MovieModel { OwnerId = ownerId, Title = string.Empty }); + var existingTvShows = await FindAllAsync(tvShowRepository, ownerId, new TvShowModel { OwnerId = ownerId, Title = string.Empty }); + var existingVideoGames = await FindAllAsync(videoGameRepository, ownerId, new VideoGameModel { OwnerId = ownerId, Title = string.Empty }); + + var alreadyImportedOrderIds = new HashSet(); + alreadyImportedOrderIds.UnionWith(AmazonImportMergeService.FindImportedOrderIds(existingBooks, b => b.OwnedVersions.Select(v => v.Reference))); + alreadyImportedOrderIds.UnionWith(AmazonImportMergeService.FindImportedOrderIds(existingMovies, m => m.OwnedVersions.Select(v => v.Reference))); + alreadyImportedOrderIds.UnionWith(AmazonImportMergeService.FindImportedOrderIds(existingTvShows, t => t.OwnedVersions.Select(v => v.Reference))); + alreadyImportedOrderIds.UnionWith(AmazonImportMergeService.FindImportedOrderIds(existingVideoGames, g => g.Platforms.Select(p => p.Reference))); await using var stream = file.OpenReadStream(); var rows = AmazonOrderPreviewService.BuildPreview(stream, alreadyImportedOrderIds); @@ -50,10 +67,10 @@ public async Task>> Preview(IFormFil } /// - /// Creates/updates books from the rows the user selected in the review UI. A row whose (normalized) - /// title matches an existing book - or a book created earlier in this same request - gets an - /// additional owned version instead of a duplicate book; see - /// . + /// Creates/updates items from the rows the user selected in the review UI, grouped by the media type + /// each row was assigned. A row whose (normalized) title matches an existing item of the same type - or + /// one created earlier in this same request - gets an additional owned copy instead of a duplicate + /// item; see . /// [HttpPost("commit")] [ProducesResponseType(200)] @@ -61,44 +78,171 @@ public async Task>> Preview(IFormFil public async Task> Commit(AmazonImportCommitRequestDto request) { var ownerId = this.GetUserId(); - var existingBooks = await FindAllBooksAsync(ownerId); + var result = new AmazonImportCommitResultDto(); + + foreach (var item in request.Items.Where(item => item.MediaType is null)) + { + throw new ArgumentException($"A media type is required to import '{item.Title}'."); + } + + var bookItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Book).ToList(); + var movieItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Movie).ToList(); + var tvShowItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.TvShow).ToList(); + var videoGameItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.VideoGame).ToList(); + + foreach (var item in videoGameItems.Where(item => string.IsNullOrWhiteSpace(item.Platform))) + { + throw new ArgumentException($"A platform is required to import '{item.Title}' as a video game."); + } + + if (bookItems.Count > 0) + { + var existingBooks = await FindAllAsync(bookRepository, ownerId, new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty }); + var (created, mergedInto, skipped) = await CommitAsync( + bookRepository, existingBooks, bookItems.Select(ToOwnedItemRequestItem).ToList(), + b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new BookModel + { + OwnerId = ownerId, + Title = item.Title, + Author = string.Empty, + Year = item.Year, + Isbn = item.Isbn, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), + OwnedVersions = [item.OwnedVersion] + }, + (book, item) => book.OwnedVersions.Add(item.OwnedVersion), ownerId); + (result.BooksCreated, result.BooksMergedInto, result.BooksSkipped) = (created, mergedInto, skipped); + } + + if (movieItems.Count > 0) + { + var existingMovies = await FindAllAsync(movieRepository, ownerId, new MovieModel { OwnerId = ownerId, Title = string.Empty }); + var (created, mergedInto, skipped) = await CommitAsync( + movieRepository, existingMovies, movieItems.Select(ToOwnedItemRequestItem).ToList(), + m => m.Title, m => m.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new MovieModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + OwnedVersions = [item.OwnedVersion] + }, + (movie, item) => movie.OwnedVersions.Add(item.OwnedVersion), ownerId); + (result.MoviesCreated, result.MoviesMergedInto, result.MoviesSkipped) = (created, mergedInto, skipped); + } + + if (tvShowItems.Count > 0) + { + var existingTvShows = await FindAllAsync(tvShowRepository, ownerId, new TvShowModel { OwnerId = ownerId, Title = string.Empty }); + var (created, mergedInto, skipped) = await CommitAsync( + tvShowRepository, existingTvShows, tvShowItems.Select(ToOwnedItemRequestItem).ToList(), + t => t.Title, t => t.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new TvShowModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + OwnedVersions = [item.OwnedVersion] + }, + (tvShow, item) => tvShow.OwnedVersions.Add(item.OwnedVersion), ownerId); + (result.TvShowsCreated, result.TvShowsMergedInto, result.TvShowsSkipped) = (created, mergedInto, skipped); + } - var items = request.Items.Select(item => new AmazonBookImportRequestItem + if (videoGameItems.Count > 0) { - Title = item.Title, - AmazonTitle = item.AmazonTitle, - Year = item.Year, - Isbn = item.Isbn, - OwnedVersion = new OwnedVersionModel - { - CopyType = Enum.Parse(item.CopyType.ToString()), - Price = item.Price, - Vendor = item.Vendor, - AcquiredAt = item.AcquiredAt, - Reference = item.Reference - } - }).ToList(); - - var plan = AmazonBookImportMergeService.ComputeCommitPlan(ownerId, existingBooks, items); - - foreach (var book in plan.BooksToCreate) + var existingVideoGames = await FindAllAsync(videoGameRepository, ownerId, new VideoGameModel { OwnerId = ownerId, Title = string.Empty }); + var (created, mergedInto, skipped) = await CommitAsync( + videoGameRepository, existingVideoGames, videoGameItems.Select(ToVideoGameRequestItem).ToList(), + g => g.Title, g => g.Platforms.Select(p => p.Reference), + i => i.Title, i => i.Platform.Reference, + item => new VideoGameModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + Platforms = [item.Platform] + }, + (game, item) => game.Platforms.Add(item.Platform), ownerId); + (result.VideoGamesCreated, result.VideoGamesMergedInto, result.VideoGamesSkipped) = (created, mergedInto, skipped); + } + + return Ok(result); + } + + private static AmazonOwnedItemImportRequestItem ToOwnedItemRequestItem(AmazonImportCommitItemDto item) => new() + { + Title = item.Title, + AmazonTitle = item.AmazonTitle, + Year = item.Year, + Isbn = item.Isbn, + OwnedVersion = ToOwnedVersion(item) + }; + + private static AmazonVideoGameImportRequestItem ToVideoGameRequestItem(AmazonImportCommitItemDto item) => new() + { + Title = item.Title, + AmazonTitle = item.AmazonTitle, + Year = item.Year, + Platform = new VideoGamePlatformModel { - await bookRepository.CreateAsync(book); + Platform = item.Platform!, + CopyType = ToDomainCopyType(item.CopyType), + Price = item.Price, + Vendor = item.Vendor, + AcquiredAt = item.AcquiredAt, + Reference = item.Reference } + }; + + private static OwnedVersionModel ToOwnedVersion(AmazonImportCommitItemDto item) => new() + { + CopyType = ToDomainCopyType(item.CopyType), + Price = item.Price, + Vendor = item.Vendor, + AcquiredAt = item.AcquiredAt, + Reference = item.Reference + }; + + private static Keeptrack.Domain.Models.CopyType ToDomainCopyType(Keeptrack.WebApi.Contracts.Dto.CopyType copyType) => + Enum.Parse(copyType.ToString()); - foreach (var book in plan.BooksToUpdate) + private static async Task<(int Created, int MergedInto, int Skipped)> CommitAsync( + IDataRepository repository, + List existingItems, + List requestItems, + Func getExistingTitle, + Func> getExistingReferences, + Func getItemTitle, + Func getItemReference, + Func createNew, + Action appendOwnedCopy, + string ownerId) + where TModel : class, IHasIdAndOwnerId + { + var plan = AmazonImportMergeService.ComputeCommitPlan( + existingItems, requestItems, getExistingTitle, getExistingReferences, getItemTitle, getItemReference, createNew, appendOwnedCopy); + + foreach (var item in plan.ItemsToCreate) { - await bookRepository.UpdateAsync(book.Id!, book, ownerId); + await repository.CreateAsync(item); } - return Ok(new AmazonImportCommitResultDto + foreach (var item in plan.ItemsToUpdate) { - BooksCreated = plan.BooksToCreate.Count, - BooksMergedInto = plan.BooksToUpdate.Count, - OwnedVersionsAdded = plan.OwnedVersionsAdded - }); + await repository.UpdateAsync(item.Id!, item, ownerId); + } + + return (plan.ItemsToCreate.Count, plan.ItemsToUpdate.Count, plan.OwnedCopiesSkipped); } - private async Task> FindAllBooksAsync(string ownerId) => - (await bookRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, new BookModel { OwnerId = ownerId, Title = string.Empty, Author = string.Empty })).Items; + private static async Task> FindAllAsync(IDataRepository repository, string ownerId, TModel blankSample) + where TModel : IHasIdAndOwnerId => + (await repository.FindAllAsync(ownerId, 1, int.MaxValue, null, blankSample)).Items; } diff --git a/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs b/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs index ac111ad9..61d7e8a6 100644 --- a/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs +++ b/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs @@ -4,27 +4,49 @@ namespace Keeptrack.WebApi.IntegrationTests.Resources; /// /// Builds a small, synthetic Amazon order-history CSV for tests - never use a real personal export as a -/// test fixture (same rule as ). +/// test fixture (same rule as ). Amazon's export has no category +/// column, so the Movie/TvShow/VideoGame rows below are just ordinary non-ISBN order lines - the test +/// itself is what assigns their media type at commit time, exactly like a real user would in the review UI. /// internal static class AmazonFixtureCsvBuilder { - public const string BookTitle = "Keeptrack Integration Test Book"; + // Deliberately namespaced "Amazon Import" (not the generic "Keeptrack Integration Test X" TvTimeFixtureZipBuilder + // uses) - MovieTitle once collided exactly with TvTimeFixtureZipBuilder.MovieTitle in the shared test database, + // which made TvTimeImportResourceTest's title-matching pick up this fixture's leftover/concurrent movie and + // report 0 created instead of 1. Keep every title here distinct from every other fixture's, not just internally + // consistent. + public const string BookTitle = "Keeptrack Amazon Import Test Book"; /// A real, checksum-valid ISBN-10 - exercises the "looks like a book" heuristic honestly. public const string BookIsbn = "0552177571"; public const string BookOrderId = "999-1111111-1111111"; - public const string NonBookTitle = "Keeptrack Integration Test Gadget"; + public const string NonBookTitle = "Keeptrack Amazon Import Test Gadget"; private const string NonBookAsin = "B000000001"; private const string NonBookOrderId = "999-2222222-2222222"; + public const string MovieTitle = "Keeptrack Amazon Import Test Movie"; + private const string MovieAsin = "B000000002"; + public const string MovieOrderId = "999-3333333-3333333"; + + public const string TvShowTitle = "Keeptrack Amazon Import Test TV Show"; + private const string TvShowAsin = "B000000003"; + public const string TvShowOrderId = "999-4444444-4444444"; + + public const string VideoGameTitle = "Keeptrack Amazon Import Test Video Game"; + private const string VideoGameAsin = "B000000004"; + public const string VideoGameOrderId = "999-5555555-5555555"; + public static byte[] Build() { var csv = $""" ASIN,Order Date,Order ID,Product Name,Product Condition,Total Amount,Website {BookIsbn},2024-01-24T09:01:58Z,{BookOrderId},{BookTitle},New,10.49,Amazon.fr {NonBookAsin},2020-06-13T21:11:47Z,{NonBookOrderId},{NonBookTitle},New,14.99,Amazon.fr + {MovieAsin},2019-05-01T10:00:00Z,{MovieOrderId},{MovieTitle},New,12.99,Amazon.fr + {TvShowAsin},2018-03-15T10:00:00Z,{TvShowOrderId},{TvShowTitle},New,29.99,Amazon.fr + {VideoGameAsin},2021-07-20T10:00:00Z,{VideoGameOrderId},{VideoGameTitle},New,49.99,Amazon.fr """; return Encoding.UTF8.GetBytes(csv); diff --git a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs index 73bde271..6acdc324 100644 --- a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Threading.Tasks; using AwesomeAssertions; using Keeptrack.Common.System; @@ -14,7 +15,7 @@ public class AmazonImportResourceTest(KestrelWebAppFactory factory) : ResourceTestBase(factory) { [Fact] - public async Task PreviewThenCommit_CreatesABookWithAnOwnedVersion_AndFlagsItAlreadyImportedOnReimport() + public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAlreadyImportedOnReimport() { await Authenticate(); @@ -25,37 +26,46 @@ public async Task PreviewThenCommit_CreatesABookWithAnOwnedVersion_AndFlagsItAlr bookRow.LooksLikeBook.Should().BeTrue(); bookRow.SuggestedIsbn.Should().Be(AmazonFixtureCsvBuilder.BookIsbn); bookRow.AlreadyImported.Should().BeFalse(); - bookRow.OrderId.Should().Be(AmazonFixtureCsvBuilder.BookOrderId); var nonBookRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.NonBookTitle).Subject; nonBookRow.LooksLikeBook.Should().BeFalse(); + var movieRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.MovieTitle).Subject; + var tvShowRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.TvShowTitle).Subject; + var videoGameRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.VideoGameTitle).Subject; + // none of these three have an ISBN-shaped ASIN - confirms the heuristic never mistakes them for books + movieRow.LooksLikeBook.Should().BeFalse(); + tvShowRow.LooksLikeBook.Should().BeFalse(); + videoGameRow.LooksLikeBook.Should().BeFalse(); + try { + // a video game row with no platform must be rejected before anything is persisted + var invalidPlatformRequest = new AmazonImportCommitRequestDto { Items = [ToCommitItem(videoGameRow, AmazonImportMediaType.VideoGame, platform: null)] }; + await PostAsync("/api/import/amazon/commit", invalidPlatformRequest, HttpStatusCode.BadRequest); + + // a row with no media type chosen must also be rejected before anything is persisted + var noMediaTypeItem = ToCommitItem(bookRow, AmazonImportMediaType.Book); + noMediaTypeItem.MediaType = null; + var invalidTypeRequest = new AmazonImportCommitRequestDto { Items = [noMediaTypeItem] }; + await PostAsync("/api/import/amazon/commit", invalidTypeRequest, HttpStatusCode.BadRequest); + var commitRequest = new AmazonImportCommitRequestDto { Items = [ - new AmazonImportCommitItemDto - { - RowId = bookRow.RowId, - Title = bookRow.Title, - AmazonTitle = bookRow.Title, - Year = 1997, - Isbn = bookRow.SuggestedIsbn, - AcquiredAt = bookRow.OrderDate, - Price = bookRow.Price, - Vendor = bookRow.Vendor, - Reference = $"Amazon order {bookRow.OrderId}", - CopyType = CopyType.Physical - } + ToCommitItem(bookRow, AmazonImportMediaType.Book, isbn: bookRow.SuggestedIsbn, year: 1997), + ToCommitItem(movieRow, AmazonImportMediaType.Movie), + ToCommitItem(tvShowRow, AmazonImportMediaType.TvShow), + ToCommitItem(videoGameRow, AmazonImportMediaType.VideoGame, platform: "PS5") ] }; var commitResult = await PostAsync("/api/import/amazon/commit", commitRequest); commitResult.BooksCreated.Should().Be(1); - commitResult.BooksMergedInto.Should().Be(0); - commitResult.OwnedVersionsAdded.Should().Be(1); + commitResult.MoviesCreated.Should().Be(1); + commitResult.TvShowsCreated.Should().Be(1); + commitResult.VideoGamesCreated.Should().Be(1); var books = await GetAsync>($"/api/books?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.BookTitle)}"); var book = books.Items.Should().ContainSingle().Subject; @@ -66,10 +76,44 @@ public async Task PreviewThenCommit_CreatesABookWithAnOwnedVersion_AndFlagsItAlr book.OwnedVersions[0].Reference.Should().Contain(AmazonFixtureCsvBuilder.BookOrderId); book.Notes.Should().Be($"Title from Amazon: {AmazonFixtureCsvBuilder.BookTitle}\nISBN from Amazon: {AmazonFixtureCsvBuilder.BookIsbn}"); - // re-preview after commit: the just-imported order must now be flagged, so re-uploading a - // newer export later doesn't silently duplicate this book + var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.MovieTitle)}"); + var movie = movies.Items.Should().ContainSingle().Subject; + movie.OwnedVersions.Should().ContainSingle(); + movie.Notes.Should().Be($"Title from Amazon: {AmazonFixtureCsvBuilder.MovieTitle}"); + + var tvShows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.TvShowTitle)}"); + var tvShow = tvShows.Items.Should().ContainSingle().Subject; + tvShow.OwnedVersions.Should().ContainSingle(); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.VideoGameTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().ContainSingle(); + videoGame.Platforms[0].Platform.Should().Be("PS5"); + videoGame.Platforms[0].Reference.Should().Contain(AmazonFixtureCsvBuilder.VideoGameOrderId); + + // re-preview after commit: every just-imported order must now be flagged, regardless of which + // type it was imported as, so re-uploading a newer export later doesn't silently duplicate any of them var secondPreview = await PostFileAsync>("/api/import/amazon/preview", "file", csv, "orders.csv"); secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.BookTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.MovieTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.TvShowTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.VideoGameTitle && r.AlreadyImported); + + // committing the exact same rows again (e.g. the user re-runs the import without noticing the + // "already imported" badge) must not duplicate anything - this is the bug reported in practice + var secondCommitResult = await PostAsync("/api/import/amazon/commit", commitRequest); + secondCommitResult.BooksCreated.Should().Be(0); + secondCommitResult.BooksMergedInto.Should().Be(0); + secondCommitResult.BooksSkipped.Should().Be(1); + secondCommitResult.MoviesSkipped.Should().Be(1); + secondCommitResult.TvShowsSkipped.Should().Be(1); + secondCommitResult.VideoGamesSkipped.Should().Be(1); + + var booksAfterReimport = await GetAsync>($"/api/books?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.BookTitle)}"); + booksAfterReimport.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); + + var videoGamesAfterReimport = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.VideoGameTitle)}"); + videoGamesAfterReimport.Items.Should().ContainSingle().Which.Platforms.Should().ContainSingle(); } finally { @@ -78,6 +122,40 @@ public async Task PreviewThenCommit_CreatesABookWithAnOwnedVersion_AndFlagsItAlr { await DeleteAsync($"/api/books/{book.Id}"); } + + var movies = await GetAsync>($"/api/movies?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.MovieTitle)}"); + foreach (var movie in movies.Items.Where(m => m.Id is not null)) + { + await DeleteAsync($"/api/movies/{movie.Id}"); + } + + var tvShows = await GetAsync>($"/api/tv-shows?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.TvShowTitle)}"); + foreach (var tvShow in tvShows.Items.Where(t => t.Id is not null)) + { + await DeleteAsync($"/api/tv-shows/{tvShow.Id}"); + } + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.VideoGameTitle)}"); + foreach (var videoGame in videoGames.Items.Where(g => g.Id is not null)) + { + await DeleteAsync($"/api/video-games/{videoGame.Id}"); + } } } + + private static AmazonImportCommitItemDto ToCommitItem(AmazonOrderPreviewRowDto row, AmazonImportMediaType mediaType, int? year = null, string? isbn = null, string? platform = null) => new() + { + RowId = row.RowId, + Title = row.Title, + AmazonTitle = row.Title, + MediaType = mediaType, + Year = year, + Isbn = isbn, + Platform = platform, + AcquiredAt = row.OrderDate, + Price = row.Price, + Vendor = row.Vendor, + Reference = $"Amazon order {row.OrderId}", + CopyType = CopyType.Physical + }; } diff --git a/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs deleted file mode 100644 index 37f536c2..00000000 --- a/test/WebApi.UnitTests/Services/AmazonBookImportMergeServiceTest.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Collections.Generic; -using AwesomeAssertions; -using Keeptrack.Domain.Models; -using Keeptrack.Domain.Services; -using Xunit; - -namespace Keeptrack.WebApi.UnitTests.Services; - -[Trait("Category", "UnitTests")] -public class AmazonBookImportMergeServiceTest -{ - private const string OwnerId = "owner-1"; - - private static BookModel Book(string title, params OwnedVersionModel[] ownedVersions) => - new() { Id = title, OwnerId = OwnerId, Title = title, Author = string.Empty, OwnedVersions = [.. ownedVersions] }; - - private static AmazonBookImportRequestItem Item(string title, string? reference = null, string? amazonTitle = null, string? isbn = null) => new() - { - Title = title, - AmazonTitle = amazonTitle ?? title, - Isbn = isbn, - OwnedVersion = new OwnedVersionModel { Reference = reference } - }; - - [Fact] - public void ComputeCommitPlan_CreatesANewBook_WhenNoExistingBookMatchesTheTitle() - { - var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [], [Item("The Secret")]); - - plan.BooksToCreate.Should().ContainSingle(); - plan.BooksToCreate[0].Title.Should().Be("The Secret"); - plan.BooksToCreate[0].Author.Should().Be(string.Empty); - plan.BooksToCreate[0].OwnedVersions.Should().ContainSingle(); - plan.BooksToUpdate.Should().BeEmpty(); - plan.OwnedVersionsAdded.Should().Be(1); - } - - [Fact] - public void ComputeCommitPlan_RecordsAmazonsOriginalTitleAndIsbnInNotes_ForANewlyCreatedBook() - { - var item = Item("The Secret", amazonTitle: "The Secret: Jack Reacher, Book 28", isbn: "0552177571"); - - var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [], [item]); - - plan.BooksToCreate[0].Notes.Should().Be("Title from Amazon: The Secret: Jack Reacher, Book 28\nISBN from Amazon: 0552177571"); - } - - [Fact] - public void ComputeCommitPlan_OmitsTheIsbnNoteLine_WhenThereIsNoIsbn() - { - var item = Item("A Book With No Isbn", amazonTitle: "A Book With No Isbn"); - - var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [], [item]); - - plan.BooksToCreate[0].Notes.Should().Be("Title from Amazon: A Book With No Isbn"); - } - - [Fact] - public void ComputeCommitPlan_DoesNotTouchNotes_WhenMergingIntoAnExistingBook() - { - var existing = Book("Some Book"); - existing.Notes = "My own pre-existing notes"; - - var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [existing], [Item("some book", amazonTitle: "Some Book (Amazon listing)")]); - - plan.BooksToCreate.Should().BeEmpty(); - existing.Notes.Should().Be("My own pre-existing notes"); - } - - [Fact] - public void ComputeCommitPlan_MergesIntoAnExistingBook_WhenTheNormalizedTitleMatches() - { - var existing = Book(" Some Book "); - - var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [existing], [Item("some book")]); - - plan.BooksToCreate.Should().BeEmpty(); - plan.BooksToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); - existing.OwnedVersions.Should().ContainSingle(); - plan.OwnedVersionsAdded.Should().Be(1); - } - - [Fact] - public void ComputeCommitPlan_MergesTwoSelectedRowsSharingATitle_IntoOneNewBook() - { - var plan = AmazonBookImportMergeService.ComputeCommitPlan(OwnerId, [], [Item("Duplicate Title"), Item("duplicate title")]); - - plan.BooksToCreate.Should().ContainSingle(); - plan.BooksToCreate[0].OwnedVersions.Should().HaveCount(2); - plan.BooksToUpdate.Should().BeEmpty(); - plan.OwnedVersionsAdded.Should().Be(2); - } - - [Fact] - public void FindImportedOrderIds_ReturnsOnlyOrderIdsFormattedByFormatOrderReference() - { - var existingBooks = new List - { - Book("Book A", new OwnedVersionModel { Reference = AmazonBookImportMergeService.FormatOrderReference("405-1111111-1111111") }), - Book("Book B", new OwnedVersionModel { Reference = "Bought at a flea market" }), - Book("Book C", new OwnedVersionModel { Reference = null }) - }; - - var result = AmazonBookImportMergeService.FindImportedOrderIds(existingBooks); - - result.Should().BeEquivalentTo(["405-1111111-1111111"]); - } -} diff --git a/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs new file mode 100644 index 00000000..254b1ab8 --- /dev/null +++ b/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs @@ -0,0 +1,211 @@ +using System.Collections.Generic; +using System.Linq; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class AmazonImportMergeServiceTest +{ + private const string OwnerId = "owner-1"; + + private static BookModel Book(string title, params OwnedVersionModel[] ownedVersions) => + new() { Id = title, OwnerId = OwnerId, Title = title, Author = string.Empty, OwnedVersions = [.. ownedVersions] }; + + private static VideoGameModel Game(string title, params VideoGamePlatformModel[] platforms) => + new() { Id = title, OwnerId = OwnerId, Title = title, Platforms = [.. platforms] }; + + private static AmazonOwnedItemImportRequestItem Item(string title, string? reference = null, string? amazonTitle = null, string? isbn = null) => new() + { + Title = title, + AmazonTitle = amazonTitle ?? title, + Isbn = isbn, + OwnedVersion = new OwnedVersionModel { Reference = reference } + }; + + /// Wires up the generic engine for BookModel, the same way AmazonImportController does. + private static AmazonImportPlan ComputeBookPlan(IReadOnlyCollection existing, IReadOnlyList items) => + AmazonImportMergeService.ComputeCommitPlan( + existing, items, + b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new BookModel + { + OwnerId = OwnerId, + Title = item.Title, + Author = string.Empty, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), + OwnedVersions = [item.OwnedVersion] + }, + (book, item) => book.OwnedVersions.Add(item.OwnedVersion)); + + [Fact] + public void ComputeCommitPlan_CreatesANewBook_WhenNoExistingBookMatchesTheTitle() + { + var plan = ComputeBookPlan([], [Item("The Secret")]); + + plan.ItemsToCreate.Should().ContainSingle(); + plan.ItemsToCreate[0].Title.Should().Be("The Secret"); + plan.ItemsToCreate[0].Author.Should().Be(string.Empty); + plan.ItemsToCreate[0].OwnedVersions.Should().ContainSingle(); + plan.ItemsToUpdate.Should().BeEmpty(); + plan.OwnedCopiesAdded.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_RecordsAmazonsOriginalTitleAndIsbnInNotes_ForANewlyCreatedBook() + { + var item = Item("The Secret", amazonTitle: "The Secret: Jack Reacher, Book 28", isbn: "0552177571"); + + var plan = ComputeBookPlan([], [item]); + + plan.ItemsToCreate[0].Notes.Should().Be("Title from Amazon: The Secret: Jack Reacher, Book 28\nISBN from Amazon: 0552177571"); + } + + [Fact] + public void ComputeCommitPlan_OmitsTheIsbnNoteLine_WhenThereIsNoIsbn() + { + var item = Item("A Book With No Isbn", amazonTitle: "A Book With No Isbn"); + + var plan = ComputeBookPlan([], [item]); + + plan.ItemsToCreate[0].Notes.Should().Be("Title from Amazon: A Book With No Isbn"); + } + + [Fact] + public void ComputeCommitPlan_DoesNotTouchNotes_WhenMergingIntoAnExistingBook() + { + var existing = Book("Some Book"); + existing.Notes = "My own pre-existing notes"; + + var plan = ComputeBookPlan([existing], [Item("some book", amazonTitle: "Some Book (Amazon listing)")]); + + plan.ItemsToCreate.Should().BeEmpty(); + existing.Notes.Should().Be("My own pre-existing notes"); + } + + [Fact] + public void ComputeCommitPlan_MergesIntoAnExistingBook_WhenTheNormalizedTitleMatches() + { + var existing = Book(" Some Book "); + + var plan = ComputeBookPlan([existing], [Item("some book")]); + + plan.ItemsToCreate.Should().BeEmpty(); + plan.ItemsToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); + existing.OwnedVersions.Should().ContainSingle(); + plan.OwnedCopiesAdded.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_MergesTwoSelectedRowsSharingATitle_IntoOneNewBook() + { + var plan = ComputeBookPlan([], [Item("Duplicate Title"), Item("duplicate title")]); + + plan.ItemsToCreate.Should().ContainSingle(); + plan.ItemsToCreate[0].OwnedVersions.Should().HaveCount(2); + plan.ItemsToUpdate.Should().BeEmpty(); + plan.OwnedCopiesAdded.Should().Be(2); + } + + [Fact] + public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceAlreadyExistsOnAnExistingBook() + { + // reproduces a real bug: re-running the same commit (or re-importing the same export) created a + // second owned version for the same order every time, because matching was title-only + var reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111"); + var existing = Book("Some Book", new OwnedVersionModel { Reference = reference }); + + var plan = ComputeBookPlan([existing], [Item("Some Book", reference: reference)]); + + plan.ItemsToCreate.Should().BeEmpty(); + plan.ItemsToUpdate.Should().BeEmpty(); + existing.OwnedVersions.Should().ContainSingle(); + plan.OwnedCopiesAdded.Should().Be(0); + plan.OwnedCopiesSkipped.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceMatchesAnExistingBook_EvenUnderADifferentTitleNow() + { + // reproduces a real bug: after a book's title was changed (e.g. by reference-data linking) following + // a first import, re-importing the same order under its original Amazon title no longer matched the + // existing book by title at all, and created a brand new duplicate record instead + var reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111"); + var existing = Book("The Secret: Jack Reacher, Book 28", new OwnedVersionModel { Reference = reference }); + existing.Title = "No Plan B"; // renamed after the first import, e.g. by reference-data linking + + var plan = ComputeBookPlan([existing], [Item("The Secret: Jack Reacher, Book 28", reference: reference)]); + + plan.ItemsToCreate.Should().BeEmpty(); + plan.ItemsToUpdate.Should().BeEmpty(); + existing.OwnedVersions.Should().ContainSingle(); + plan.OwnedCopiesSkipped.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_StillMergesASecondCopy_WhenTheOrderReferenceIsGenuinelyDifferent() + { + var existing = Book("Some Book", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111") }); + + var plan = ComputeBookPlan([existing], [Item("some book", reference: AmazonImportMergeService.FormatOrderReference("405-9999999-9999999"))]); + + plan.ItemsToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); + existing.OwnedVersions.Should().HaveCount(2); + plan.OwnedCopiesSkipped.Should().Be(0); + } + + [Fact] + public void ComputeCommitPlan_WorksUnmodifiedForVideoGames_ViaPlatformsInsteadOfOwnedVersions() + { + var item = new AmazonVideoGameImportRequestItem + { + Title = "L.A. Noire", + AmazonTitle = "L.A. Noire", + Platform = new VideoGamePlatformModel { Platform = "PS3" } + }; + + var plan = AmazonImportMergeService.ComputeCommitPlan( + new List(), [item], + g => g.Title, g => g.Platforms.Select(p => p.Reference), + i => i.Title, i => i.Platform.Reference, + requestItem => new VideoGameModel { OwnerId = OwnerId, Title = requestItem.Title, Platforms = [requestItem.Platform] }, + (game, requestItem) => game.Platforms.Add(requestItem.Platform)); + + plan.ItemsToCreate.Should().ContainSingle(); + plan.ItemsToCreate[0].Platforms.Should().ContainSingle().Which.Platform.Should().Be("PS3"); + plan.OwnedCopiesAdded.Should().Be(1); + } + + [Fact] + public void FindImportedOrderIds_ReturnsOnlyOrderIdsFormattedByFormatOrderReference() + { + var existingBooks = new List + { + Book("Book A", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111") }), + Book("Book B", new OwnedVersionModel { Reference = "Bought at a flea market" }), + Book("Book C", new OwnedVersionModel { Reference = null }) + }; + + var result = AmazonImportMergeService.FindImportedOrderIds(existingBooks, b => b.OwnedVersions.Select(v => v.Reference)); + + result.Should().BeEquivalentTo(["405-1111111-1111111"]); + } + + [Fact] + public void FindImportedOrderIds_WorksAcrossVideoGamePlatformsToo() + { + var existingGames = new List + { + Game("Game A", new VideoGamePlatformModel { Platform = "PS5", Reference = AmazonImportMergeService.FormatOrderReference("405-2222222-2222222") }), + Game("Game B", new VideoGamePlatformModel { Platform = "PC", Reference = null }) + }; + + var result = AmazonImportMergeService.FindImportedOrderIds(existingGames, g => g.Platforms.Select(p => p.Reference)); + + result.Should().BeEquivalentTo(["405-2222222-2222222"]); + } +} From dbef5020f283651694832b7430125dff9c3a479a Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 19:04:16 +0200 Subject: [PATCH 05/35] Remove peristentstate from tv show detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [PersistentState] is fully reverted in TvShowDetail.razor — Show/Reference/Episodes are back to private fields _show/_reference/_episodes, OnParametersSetAsync always calls LoadAsync (no restore-skip path), matching how Watch Next handles it. The _loading/_loaded flash fix stays untouched. Build passes. I also updated docs/prerender-flash-fix.md to record why (unbounded embedded Episodes list hit the same 32 KB SignalR ceiling as Watch Next's aggregate list). --- docs/prerender-flash-fix.md | 8 + .../Inventory/Pages/TvShowDetail.razor | 158 +++++++----------- 2 files changed, 73 insertions(+), 93 deletions(-) diff --git a/docs/prerender-flash-fix.md b/docs/prerender-flash-fix.md index 63137a6d..1c3901fe 100644 --- a/docs/prerender-flash-fix.md +++ b/docs/prerender-flash-fix.md @@ -113,6 +113,14 @@ Two real options, and I have a clear preference: The detail pages persist a single bounded DTO and are fine to keep as-is. 2. Raise the limit via .AddHubOptions(o => o.MaximumReceiveMessageSize = 256 * 1024) — one line, keeps the no-refetch behavior, but the payload still grows with your collection and the cliff just moves further out. +**Update**: "the detail pages persist a single bounded DTO" turned out not to hold for TvShowDetail. +`TvShowReferenceModel.Episodes` is embedded (see CLAUDE.md's "Reference data" section) and unbounded per show, +so `[PersistentState]`-serializing `Reference` plus the raw `Episodes` list hit the exact same 32 KB SignalR ceiling for shows with many seasons/episodes. +`[PersistentState]` was reverted from `TvShowDetail.razor` +(back to plain private fields `_show`/`_reference`/`_episodes`, `OnParametersSetAsync` always calls `LoadAsync`, no restore-skip path) for the same reason it was dropped from Watch Next/Wishlist above. +The `_loaded`/`_loading` flash fix from "Fix loading flash" stays — it isn't part of the persisted-state mechanism and never caused this issue. +Movie/Book/Album/VideoGame/Car/House/HealthProfile/Playlist detail pages keep `[PersistentState]` since their persisted DTOs stay genuinely bounded (no embedded unbounded list like `Episodes`). + Sources: [dotnet/aspnetcore#65101](https://github.com/dotnet/aspnetcore/issues/65101), [Blazor SignalR guidance — message size](https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/signalr), diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor index f350c04d..f501fab0 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -13,7 +13,7 @@
} } -else if (Show is null) +else if (_show is null) {
@@ -22,13 +22,13 @@ else if (Show is null) } else { - +
- + @if (_refreshMessage is not null) @@ -37,71 +37,71 @@ else }
- - - + + +
- - - + + +
- @if (string.IsNullOrEmpty(Show.ReferenceId)) + @if (string.IsNullOrEmpty(_show.ReferenceId)) { - + }
- @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) + @if (!string.IsNullOrEmpty(_reference?.ImageUrl)) {
- @Show.Title poster + @_show.Title poster
}
+ value="@_show.Year" @onchange="SaveYearAsync"/>
- +
- @if (Reference?.Genres.Count > 0) + @if (_reference?.Genres.Count > 0) { -

@string.Join(", ", Reference.Genres)

+

@string.Join(", ", _reference.Genres)

}
- +
- @if (!string.IsNullOrEmpty(Reference?.Synopsis)) + @if (!string.IsNullOrEmpty(_reference?.Synopsis)) {
-

@Reference.Synopsis

+

@_reference.Synopsis

}
- @if (Reference?.Cast.Count > 0) + @if (_reference?.Cast.Count > 0) {

Cast

- + } - + - @if (Reference is not null) + @if (_reference is not null) { @if (_seasons.Count == 0) { @@ -221,20 +221,9 @@ else // of a spinner flash on every navigation to this page. private bool _loading; private bool _loaded; - - // Public properties (a framework requirement for [PersistentState]): the data loaded during the - // prerender pass is carried over to the interactive circuit, so the first interactive render reuses - // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. The derived - // season/watched lookups are rebuilt locally from these (a tuple-keyed dictionary can't round-trip - // through the JSON-serialized persisted state). - [PersistentState] - public TvShowDto? Show { get; set; } - - [PersistentState] - public TvShowReferenceDto? Reference { get; set; } - - [PersistentState] - public List? Episodes { get; set; } + private TvShowDto? _show; + private TvShowReferenceDto? _reference; + private List? _episodes; private Dictionary<(int Season, int Episode), EpisodeDto> _watchedByKey = new(); private Dictionary> _referenceEpisodesBySeason = new(); private List _seasons = []; @@ -252,19 +241,7 @@ else private int _newEpisode = 1; private DateOnly? _newWatchedAt = DateOnly.FromDateTime(DateTime.Today); - protected override async Task OnParametersSetAsync() - { - // Show already holds this route's item when PersistentState restored the prerendered data - // the id check keeps an in-circuit navigation to a different show reloading as before - if (Show?.Id == Id && Episodes is not null) - { - BuildDerivedState(); - _loading = false; - _loaded = true; - return; - } - await LoadAsync(); - } + protected override async Task OnParametersSetAsync() => await LoadAsync(); private async Task LoadAsync() { @@ -275,28 +252,23 @@ else private async Task FetchAsync() { - Show = await TvShowApi.GetOneAsync(Id); + _show = await TvShowApi.GetOneAsync(Id); var episodes = await EpisodeApi.GetAsync(string.Empty, 1, 5000, new Dictionary { ["TvShowId"] = Id }); - Episodes = episodes.Items; + _episodes = episodes.Items; // reference data (synopsis, real episode titles, cast, poster) is a progressive enhancement: it // may not exist yet (background match still pending, or genuinely unresolved), so the page never // blocks on it - see the two very different rendering branches below. - Reference = string.IsNullOrEmpty(Show?.ReferenceId) ? null : await ReferenceDataApi.GetTvShowAsync(Show.ReferenceId); - BuildDerivedState(); - } - - private void BuildDerivedState() - { - _watchedByKey = Episodes!.ToDictionary(e => (e.SeasonNumber, e.EpisodeNumber)); + _reference = string.IsNullOrEmpty(_show?.ReferenceId) ? null : await ReferenceDataApi.GetTvShowAsync(_show.ReferenceId); + _watchedByKey = _episodes.ToDictionary(e => (e.SeasonNumber, e.EpisodeNumber)); - if (Reference is not null) + if (_reference is not null) { // full checklist, in natural watch-through order - an episode TMDB lists with a future air date // hasn't aired yet, so it isn't "unseen and ready to watch", it doesn't exist yet from the // viewer's perspective (same air-date filter WatchNextService applies for the "next episode" calc) var today = DateOnly.FromDateTime(DateTime.Today); - _referenceEpisodesBySeason = Reference.Episodes + _referenceEpisodesBySeason = _reference.Episodes .Where(e => e.AirDate is null || e.AirDate <= today) .GroupBy(e => e.SeasonNumber) .ToDictionary(g => g.Key, g => g.OrderBy(e => e.EpisodeNumber).ToList()); @@ -306,7 +278,7 @@ else else { // unresolved: only what's actually been recorded, most recently aired first - _unresolvedEpisodesBySeason = Episodes! + _unresolvedEpisodesBySeason = _episodes .GroupBy(e => e.SeasonNumber) .ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.EpisodeNumber).ToList()); _unresolvedSeasons = _unresolvedEpisodesBySeason.Keys.OrderByDescending(s => s).ToList(); @@ -318,36 +290,36 @@ else private async Task ToggleFavoriteAsync() { - if (Show is null) return; - Show.IsFavorite = !Show.IsFavorite; - await TvShowApi.UpdateAsync(Show); + if (_show is null) return; + _show.IsFavorite = !_show.IsFavorite; + await TvShowApi.UpdateAsync(_show); } private async Task ToggleWantToWatchAsync() { - if (Show is null) return; - Show.WantToWatch = !Show.WantToWatch; - await TvShowApi.UpdateAsync(Show); + if (_show is null) return; + _show.WantToWatch = !_show.WantToWatch; + await TvShowApi.UpdateAsync(_show); } private async Task SaveShowAsync() { - if (Show is null) return; - await TvShowApi.UpdateAsync(Show); + if (_show is null) return; + await TvShowApi.UpdateAsync(_show); } private async Task ToggleWishlistedAsync() { - if (Show is null) return; - Show.IsWishlisted = !Show.IsWishlisted; - await TvShowApi.UpdateAsync(Show); + if (_show is null) return; + _show.IsWishlisted = !_show.IsWishlisted; + await TvShowApi.UpdateAsync(_show); } private async Task SetRatingAsync(float rating) { - if (Show is null) return; - Show.Rating = rating; - await TvShowApi.UpdateAsync(Show); + if (_show is null) return; + _show.Rating = rating; + await TvShowApi.UpdateAsync(_show); } /// @@ -357,30 +329,30 @@ else /// private async Task SetStateAsync(TvShowStatus state) { - if (Show is null) return; - Show.State = Show.State == state ? null : state; - await TvShowApi.UpdateAsync(Show); + if (_show is null) return; + _show.State = _show.State == state ? null : state; + await TvShowApi.UpdateAsync(_show); } private async Task SaveTitleAsync(ChangeEventArgs e) { - if (Show is null) return; + if (_show is null) return; var title = e.Value?.ToString(); if (string.IsNullOrWhiteSpace(title)) return; - Show.Title = title; - await TvShowApi.UpdateAsync(Show); + _show.Title = title; + await TvShowApi.UpdateAsync(_show); } private async Task RefreshReferenceAsync() { - if (Show?.Id is null) return; + if (_show?.Id is null) return; - var previousReferenceId = Show.ReferenceId; + var previousReferenceId = _show.ReferenceId; _refreshingReference = true; _refreshMessage = null; try { - var refreshed = await TvShowApi.RefreshReferenceAsync(Show.Id); + var refreshed = await TvShowApi.RefreshReferenceAsync(_show.Id); await LoadAsync(); (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) @@ -421,16 +393,16 @@ else private async Task SaveYearAsync(ChangeEventArgs e) { - if (Show is null) return; - Show.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; - await TvShowApi.UpdateAsync(Show); + if (_show is null) return; + _show.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await TvShowApi.UpdateAsync(_show); } private async Task SaveNotesAsync(ChangeEventArgs e) { - if (Show is null) return; - Show.Notes = e.Value?.ToString(); - await TvShowApi.UpdateAsync(Show); + if (_show is null) return; + _show.Notes = e.Value?.ToString(); + await TvShowApi.UpdateAsync(_show); } private bool IsSelectedSeasonFullyWatched() => From 54130a3589efcde431f38603237fa50fc62121ae Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 19 Jul 2026 22:58:20 +0200 Subject: [PATCH 06/35] Add ASIN in amazon imported items --- .../Components/Import/AmazonImportPage.razor | 12 ++-- .../Services/AmazonImportMergeService.cs | 28 +++++----- .../Services/AmazonOrderPreviewService.cs | 11 ++-- .../Dto/AmazonImportCommitItemDto.cs | 14 ++++- .../Controllers/AmazonImportController.cs | 18 +++--- .../Resources/AmazonImportResourceTest.cs | 56 ++++++++++++++++++- .../Services/AmazonImportMergeServiceTest.cs | 51 +++++++++++++---- .../Services/AmazonOrderPreviewServiceTest.cs | 23 +++++++- 8 files changed, 165 insertions(+), 48 deletions(-) diff --git a/src/BlazorApp/Components/Import/AmazonImportPage.razor b/src/BlazorApp/Components/Import/AmazonImportPage.razor index 9c80189f..bf116ee4 100644 --- a/src/BlazorApp/Components/Import/AmazonImportPage.razor +++ b/src/BlazorApp/Components/Import/AmazonImportPage.razor @@ -221,6 +221,11 @@ var items = SelectedRows.Select(row => new AmazonImportCommitItemDto { RowId = row.Preview.RowId, + // Echoed back rather than trusting a client-computed Reference string - the server derives + // the actual owned-copy Reference (and dedup key) from these two itself, which is what makes + // two different items sharing one Amazon order distinguishable from each other. + OrderId = row.Preview.OrderId, + Asin = row.Preview.Asin, Title = row.Title, // The original, unedited title from the parsed preview - kept separate from the editable // row.Title above so it can be recorded verbatim in the created item's notes, regardless of @@ -238,7 +243,6 @@ AcquiredAt = row.AcquiredAt, Price = row.Price, Vendor = row.Vendor, - Reference = row.Reference, CopyType = row.CopyType }).ToList(); @@ -265,10 +269,7 @@ Isbn = preview.SuggestedIsbn, AcquiredAt = preview.OrderDate, Price = preview.Price, - Vendor = preview.Vendor, - // Mirrors AmazonImportMergeService.FormatOrderReference's format server-side - this is just the - // review UI's own display/commit value, not import logic, so it stays a plain string here. - Reference = $"Amazon order {preview.OrderId}" + Vendor = preview.Vendor }; private sealed class EditableRow @@ -292,7 +293,6 @@ public DateOnly? AcquiredAt { get; set; } public decimal? Price { get; set; } public string? Vendor { get; set; } - public string? Reference { get; set; } /// The other field worth a decision - Amazon's export can't reliably tell physical from digital. public CopyType CopyType { get; set; } diff --git a/src/Domain/Services/AmazonImportMergeService.cs b/src/Domain/Services/AmazonImportMergeService.cs index fbaeea09..789272e2 100644 --- a/src/Domain/Services/AmazonImportMergeService.cs +++ b/src/Domain/Services/AmazonImportMergeService.cs @@ -22,26 +22,28 @@ namespace Keeptrack.Domain.Services; ///
public static class AmazonImportMergeService { - private const string OrderReferencePrefix = "Amazon order "; - /// - /// The one place that formats an owned copy's Reference for an imported order - human-readable, - /// and also the dedup key looks for on a later re-import. + /// The one place that formats an owned copy's Reference for an imported order line - human-readable, + /// and also the exact-match dedup key looks for on a later + /// re-import. Includes the ASIN, not just the order id: a single Amazon order commonly contains several + /// different line items, and order-id-only matching (a real bug, found before this shipped) meant a + /// second, genuinely different item from the same order was silently skipped as a "duplicate" of the + /// first - or, at preview time, incorrectly flagged "already imported" just because a sibling item from + /// the same order had been. The ASIN is Amazon's own stable per-product id, already parsed from every + /// row, so it's a precise disambiguator - unlike the product title, which is user-editable before commit. /// - public static string FormatOrderReference(string orderId) => OrderReferencePrefix + orderId; + public static string FormatOrderReference(string orderId, string asin) => $"Amazon order {orderId} (ASIN {asin})"; /// - /// Order ids already referenced by an existing owned copy - used at preview time to flag rows that look - /// like they were imported before, so re-uploading a newer export doesn't duplicate them. + /// Every reference already recorded on an existing owned copy - used at preview time to flag rows that + /// look like they were imported before, so re-uploading a newer export doesn't duplicate them. Compared + /// by exact string equality against computed for a candidate row, + /// which is what makes the per-line-item (not per-order) precision above actually take effect. /// reads whichever collection carries the owned copies for /// (OwnedVersions or Platforms). /// - public static HashSet FindImportedOrderIds(IEnumerable existingItems, Func> getReferences) => - existingItems - .SelectMany(getReferences) - .Where(reference => reference is not null && reference.StartsWith(OrderReferencePrefix, StringComparison.Ordinal)) - .Select(reference => reference![OrderReferencePrefix.Length..]) - .ToHashSet(); + public static HashSet FindImportedReferences(IEnumerable existingItems, Func> getReferences) => + existingItems.SelectMany(getReferences).Where(reference => reference is not null).ToHashSet()!; /// /// / make the order diff --git a/src/Domain/Services/AmazonOrderPreviewService.cs b/src/Domain/Services/AmazonOrderPreviewService.cs index f806e75d..23bce9b9 100644 --- a/src/Domain/Services/AmazonOrderPreviewService.cs +++ b/src/Domain/Services/AmazonOrderPreviewService.cs @@ -14,8 +14,8 @@ namespace Keeptrack.Domain.Services; /// /// Parses an Amazon.fr order-history export ("Request My Data" -> "Your Orders") into review rows. Pure: -/// bytes in, rows out, no repository access - below is computed by -/// the caller from already-fetched data so this stays testable without a database. +/// bytes in, rows out, no repository access - alreadyImportedReferences below is computed by the +/// caller from already-fetched data so this stays testable without a database. /// Amazon's export has no category column at all, so this never decides "is this a book" on its own - it /// only computes as a checksum-verified suggestion for /// the review UI's default filter, confirmed against a real export (an Amazon book's ASIN is routinely its @@ -52,7 +52,7 @@ private sealed class AmazonOrderRecord PrepareHeaderForMatch = args => args.Header.Trim().ToLowerInvariant() }; - public static List BuildPreview(Stream csvStream, IReadOnlySet alreadyImportedOrderIds) + public static List BuildPreview(Stream csvStream, IReadOnlySet alreadyImportedReferences) { using var reader = new StreamReader(csvStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); using var csv = new CsvReader(reader, s_csvConfiguration); @@ -79,7 +79,10 @@ public static List BuildPreview(Stream csvStream, IReadOn Condition = record.ProductCondition, LooksLikeBook = suggestedIsbn is not null, SuggestedIsbn = suggestedIsbn, - AlreadyImported = alreadyImportedOrderIds.Contains(record.OrderId) + // Per (order, ASIN), not per order - an order commonly has several different line items, and + // an order-id-only check would incorrectly flag every sibling item once any one of them had + // actually been imported (see AmazonImportMergeService.FormatOrderReference). + AlreadyImported = alreadyImportedReferences.Contains(AmazonImportMergeService.FormatOrderReference(record.OrderId, record.Asin)) }; }).ToList(); } diff --git a/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs b/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs index bb769253..6d54e891 100644 --- a/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs +++ b/src/WebApi.Contracts/Dto/AmazonImportCommitItemDto.cs @@ -13,6 +13,18 @@ public class AmazonImportCommitItemDto /// public required string RowId { get; set; } + /// + /// Amazon's own product id for this order line and the order number itself - together they're the + /// server-derived owned-copy Reference (see AmazonImportMergeService.FormatOrderReference + /// in the WebApi project), which also doubles as the exact dedup key. Both are echoed back from the + /// preview row rather than accepting a client-supplied Reference string directly, so the format + /// can't drift out of sync with what a later re-preview checks against, and so a single order's several + /// different line items (same order id, different ASIN) are never conflated with one another. + /// + public required string OrderId { get; set; } + + public required string Asin { get; set; } + public required string Title { get; set; } /// @@ -52,7 +64,5 @@ public class AmazonImportCommitItemDto public string? Vendor { get; set; } - public string? Reference { get; set; } - public CopyType CopyType { get; set; } } diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs index 41bb9fc9..56c4343e 100644 --- a/src/WebApi/Controllers/AmazonImportController.cs +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -54,14 +54,14 @@ public async Task>> Preview(IFormFil var existingTvShows = await FindAllAsync(tvShowRepository, ownerId, new TvShowModel { OwnerId = ownerId, Title = string.Empty }); var existingVideoGames = await FindAllAsync(videoGameRepository, ownerId, new VideoGameModel { OwnerId = ownerId, Title = string.Empty }); - var alreadyImportedOrderIds = new HashSet(); - alreadyImportedOrderIds.UnionWith(AmazonImportMergeService.FindImportedOrderIds(existingBooks, b => b.OwnedVersions.Select(v => v.Reference))); - alreadyImportedOrderIds.UnionWith(AmazonImportMergeService.FindImportedOrderIds(existingMovies, m => m.OwnedVersions.Select(v => v.Reference))); - alreadyImportedOrderIds.UnionWith(AmazonImportMergeService.FindImportedOrderIds(existingTvShows, t => t.OwnedVersions.Select(v => v.Reference))); - alreadyImportedOrderIds.UnionWith(AmazonImportMergeService.FindImportedOrderIds(existingVideoGames, g => g.Platforms.Select(p => p.Reference))); + var alreadyImportedReferences = new HashSet(); + alreadyImportedReferences.UnionWith(AmazonImportMergeService.FindImportedReferences(existingBooks, b => b.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(AmazonImportMergeService.FindImportedReferences(existingMovies, m => m.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(AmazonImportMergeService.FindImportedReferences(existingTvShows, t => t.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(AmazonImportMergeService.FindImportedReferences(existingVideoGames, g => g.Platforms.Select(p => p.Reference))); await using var stream = file.OpenReadStream(); - var rows = AmazonOrderPreviewService.BuildPreview(stream, alreadyImportedOrderIds); + var rows = AmazonOrderPreviewService.BuildPreview(stream, alreadyImportedReferences); return Ok(rows.Select(previewMapper.ToDto).ToList()); } @@ -197,7 +197,7 @@ public async Task> Commit(AmazonImport Price = item.Price, Vendor = item.Vendor, AcquiredAt = item.AcquiredAt, - Reference = item.Reference + Reference = AmazonImportMergeService.FormatOrderReference(item.OrderId, item.Asin) } }; @@ -207,7 +207,9 @@ public async Task> Commit(AmazonImport Price = item.Price, Vendor = item.Vendor, AcquiredAt = item.AcquiredAt, - Reference = item.Reference + // Derived server-side from the order id + ASIN the preview row reported, never from a client-supplied + // Reference string - this is what disambiguates two different items sharing one Amazon order. + Reference = AmazonImportMergeService.FormatOrderReference(item.OrderId, item.Asin) }; private static Keeptrack.Domain.Models.CopyType ToDomainCopyType(Keeptrack.WebApi.Contracts.Dto.CopyType copyType) => diff --git a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs index 6acdc324..e560837b 100644 --- a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs @@ -146,6 +146,8 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl private static AmazonImportCommitItemDto ToCommitItem(AmazonOrderPreviewRowDto row, AmazonImportMediaType mediaType, int? year = null, string? isbn = null, string? platform = null) => new() { RowId = row.RowId, + OrderId = row.OrderId, + Asin = row.Asin, Title = row.Title, AmazonTitle = row.Title, MediaType = mediaType, @@ -155,7 +157,59 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl AcquiredAt = row.OrderDate, Price = row.Price, Vendor = row.Vendor, - Reference = $"Amazon order {row.OrderId}", CopyType = CopyType.Physical }; + + [Fact] + public async Task Commit_ImportsBothItems_WhenTwoDifferentBooksShareTheSameOrder() + { + // reproduces a real bug: an Amazon order commonly contains several different line items, but the + // owned-copy Reference used to be built from the order id alone - so the second book from the same + // order was silently skipped as a "duplicate" of the first + await Authenticate(); + + const string sharedOrderId = "999-6666666-6666666"; + const string firstTitle = "Keeptrack Amazon Import Test Multi-Item Book A"; + const string secondTitle = "Keeptrack Amazon Import Test Multi-Item Book B"; + + var request = new AmazonImportCommitRequestDto + { + Items = + [ + new AmazonImportCommitItemDto + { + RowId = $"{sharedOrderId}:1111111111", OrderId = sharedOrderId, Asin = "1111111111", + Title = firstTitle, AmazonTitle = firstTitle, MediaType = AmazonImportMediaType.Book, CopyType = CopyType.Physical + }, + new AmazonImportCommitItemDto + { + RowId = $"{sharedOrderId}:2222222222", OrderId = sharedOrderId, Asin = "2222222222", + Title = secondTitle, AmazonTitle = secondTitle, MediaType = AmazonImportMediaType.Book, CopyType = CopyType.Physical + } + ] + }; + + try + { + var commitResult = await PostAsync("/api/import/amazon/commit", request); + commitResult.BooksCreated.Should().Be(2); + commitResult.BooksSkipped.Should().Be(0); + + var firstBook = (await GetAsync>($"/api/books?search={Uri.EscapeDataString(firstTitle)}")).Items.Should().ContainSingle().Subject; + var secondBook = (await GetAsync>($"/api/books?search={Uri.EscapeDataString(secondTitle)}")).Items.Should().ContainSingle().Subject; + firstBook.OwnedVersions.Should().ContainSingle().Which.Reference.Should().Contain("1111111111"); + secondBook.OwnedVersions.Should().ContainSingle().Which.Reference.Should().Contain("2222222222"); + } + finally + { + foreach (var title in new[] { firstTitle, secondTitle }) + { + var books = await GetAsync>($"/api/books?search={Uri.EscapeDataString(title)}"); + foreach (var book in books.Items.Where(b => b.Id is not null)) + { + await DeleteAsync($"/api/books/{book.Id}"); + } + } + } + } } diff --git a/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs index 254b1ab8..0a78547f 100644 --- a/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs +++ b/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs @@ -11,6 +11,8 @@ namespace Keeptrack.WebApi.UnitTests.Services; public class AmazonImportMergeServiceTest { private const string OwnerId = "owner-1"; + private const string SomeOrderId = "405-1111111-1111111"; + private const string SomeAsin = "0552177571"; private static BookModel Book(string title, params OwnedVersionModel[] ownedVersions) => new() { Id = title, OwnerId = OwnerId, Title = title, Author = string.Empty, OwnedVersions = [.. ownedVersions] }; @@ -116,7 +118,7 @@ public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceAlreadyExistsOnAnEx { // reproduces a real bug: re-running the same commit (or re-importing the same export) created a // second owned version for the same order every time, because matching was title-only - var reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111"); + var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); var existing = Book("Some Book", new OwnedVersionModel { Reference = reference }); var plan = ComputeBookPlan([existing], [Item("Some Book", reference: reference)]); @@ -134,7 +136,7 @@ public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceMatchesAnExistingBo // reproduces a real bug: after a book's title was changed (e.g. by reference-data linking) following // a first import, re-importing the same order under its original Amazon title no longer matched the // existing book by title at all, and created a brand new duplicate record instead - var reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111"); + var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); var existing = Book("The Secret: Jack Reacher, Book 28", new OwnedVersionModel { Reference = reference }); existing.Title = "No Plan B"; // renamed after the first import, e.g. by reference-data linking @@ -149,15 +151,33 @@ public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceMatchesAnExistingBo [Fact] public void ComputeCommitPlan_StillMergesASecondCopy_WhenTheOrderReferenceIsGenuinelyDifferent() { - var existing = Book("Some Book", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111") }); + var existing = Book("Some Book", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin) }); - var plan = ComputeBookPlan([existing], [Item("some book", reference: AmazonImportMergeService.FormatOrderReference("405-9999999-9999999"))]); + var plan = ComputeBookPlan([existing], [Item("some book", reference: AmazonImportMergeService.FormatOrderReference("405-9999999-9999999", SomeAsin))]); plan.ItemsToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); existing.OwnedVersions.Should().HaveCount(2); plan.OwnedCopiesSkipped.Should().Be(0); } + [Fact] + public void ComputeCommitPlan_ImportsBothItems_WhenTwoDifferentItemsShareTheSameOrderId() + { + // reproduces a real bug: an Amazon order commonly contains several different line items, but the + // reference used to be built from the order id alone - so the second item in the same order was + // silently skipped as a "duplicate" of the first, even though it's a genuinely different product + const string sharedOrderId = "405-2222222-2222222"; + var firstItem = Item("First Book In Order", reference: AmazonImportMergeService.FormatOrderReference(sharedOrderId, "1111111111")); + var secondItem = Item("Second Book In Order", reference: AmazonImportMergeService.FormatOrderReference(sharedOrderId, "2222222222")); + + var plan = ComputeBookPlan([], [firstItem, secondItem]); + + plan.ItemsToCreate.Should().HaveCount(2); + plan.ItemsToCreate.Select(b => b.Title).Should().BeEquivalentTo(["First Book In Order", "Second Book In Order"]); + plan.OwnedCopiesAdded.Should().Be(2); + plan.OwnedCopiesSkipped.Should().Be(0); + } + [Fact] public void ComputeCommitPlan_WorksUnmodifiedForVideoGames_ViaPlatformsInsteadOfOwnedVersions() { @@ -181,31 +201,38 @@ public void ComputeCommitPlan_WorksUnmodifiedForVideoGames_ViaPlatformsInsteadOf } [Fact] - public void FindImportedOrderIds_ReturnsOnlyOrderIdsFormattedByFormatOrderReference() + public void FormatOrderReference_IncludesBothTheOrderIdAndTheAsin() + { + AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin).Should().Be($"Amazon order {SomeOrderId} (ASIN {SomeAsin})"); + } + + [Fact] + public void FindImportedReferences_ReturnsOnlyNonNullReferences() { var existingBooks = new List { - Book("Book A", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111") }), + Book("Book A", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin) }), Book("Book B", new OwnedVersionModel { Reference = "Bought at a flea market" }), Book("Book C", new OwnedVersionModel { Reference = null }) }; - var result = AmazonImportMergeService.FindImportedOrderIds(existingBooks, b => b.OwnedVersions.Select(v => v.Reference)); + var result = AmazonImportMergeService.FindImportedReferences(existingBooks, b => b.OwnedVersions.Select(v => v.Reference)); - result.Should().BeEquivalentTo(["405-1111111-1111111"]); + result.Should().BeEquivalentTo([AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin), "Bought at a flea market"]); } [Fact] - public void FindImportedOrderIds_WorksAcrossVideoGamePlatformsToo() + public void FindImportedReferences_WorksAcrossVideoGamePlatformsToo() { + var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); var existingGames = new List { - Game("Game A", new VideoGamePlatformModel { Platform = "PS5", Reference = AmazonImportMergeService.FormatOrderReference("405-2222222-2222222") }), + Game("Game A", new VideoGamePlatformModel { Platform = "PS5", Reference = reference }), Game("Game B", new VideoGamePlatformModel { Platform = "PC", Reference = null }) }; - var result = AmazonImportMergeService.FindImportedOrderIds(existingGames, g => g.Platforms.Select(p => p.Reference)); + var result = AmazonImportMergeService.FindImportedReferences(existingGames, g => g.Platforms.Select(p => p.Reference)); - result.Should().BeEquivalentTo(["405-2222222-2222222"]); + result.Should().BeEquivalentTo([reference]); } } diff --git a/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs index 8a335e18..47438ae5 100644 --- a/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs +++ b/test/WebApi.UnitTests/Services/AmazonOrderPreviewServiceTest.cs @@ -81,13 +81,32 @@ public void BuildPreview_StripsAmazonsExcelFormulaInjectionApostropheBeforeParsi } [Fact] - public void BuildPreview_FlagsARowWhoseOrderIdIsAlreadyImported() + public void BuildPreview_FlagsARowWhoseOrderIdAndAsinAreAlreadyImported() { - var alreadyImported = new HashSet { "405-2296545-4493925" }; + var alreadyImported = new HashSet { AmazonImportMergeService.FormatOrderReference("405-2296545-4493925", "0552177571") }; var result = AmazonOrderPreviewService.BuildPreview(ToStream(Csv), alreadyImported); result[0].AlreadyImported.Should().BeTrue(); result[1].AlreadyImported.Should().BeFalse(); } + + [Fact] + public void BuildPreview_DoesNotFlagADifferentItemFromTheSameOrder() + { + // reproduces a real bug: an order commonly contains several different line items, so matching by + // order id alone incorrectly flagged every sibling item once any one of them had been imported + const string csv = """ + ASIN,Order Date,Order ID,Product Name,Product Condition,Total Amount,Website + 0552177571,2024-01-24T09:01:58Z,405-2296545-4493925,"The Secret",New,10.49,Amazon.fr + B002KMW6ZI,2024-01-24T09:01:58Z,405-2296545-4493925,"Some Gadget",New,14.99,Amazon.fr + """; + // only the first item (ASIN 0552177571) of this order was actually imported before + var alreadyImported = new HashSet { AmazonImportMergeService.FormatOrderReference("405-2296545-4493925", "0552177571") }; + + var result = AmazonOrderPreviewService.BuildPreview(ToStream(csv), alreadyImported); + + result[0].AlreadyImported.Should().BeTrue(); + result[1].AlreadyImported.Should().BeFalse(); + } } From 306127e9d25c7367eb098e0f31313b27aa92f17c Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 20 Jul 2026 01:07:45 +0200 Subject: [PATCH 07/35] Implemented "Cover image URL" (CustomImageUrl) override for VideoGame and Album, reproducing Book's exact pattern end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: added CustomImageUrl to VideoGameModel/AlbumModel (Domain), VideoGame/Album entities (custom_image_url BSON field), VideoGameDto/AlbumDto (with matching doc comments), and updated VideoGameController/AlbumController.OnListMappedAsync to apply the override after reference-cover hydration, same as BookController. No mapper changes needed — it's a plain matching-name scalar, auto-mapped by Mapperly. UI: both detail pages now compute coverUrl with the same precedence (CustomImageUrl if set, else the linked reference's cover), and have a "Cover image URL" text input with the same conditional placeholder and @onchange save pattern as BookDetail.razor. Tests: added *ResourceList_CustomImageUrlOverridesTheLinkedReferencesCover integration tests for both types (mirroring Book's), plus CustomImageUrl fuzzing in the existing full-cycle round-trip tests. All 12 Book/VideoGame/Album integration tests pass against real MongoDB. Visual verification: extended MobileScreenshotTest (the project's established visual-review harness) to seed a custom cover URL on the seeded Album/VideoGame and capture their detail pages by title. Ran it with E2E_SCREENSHOTS=true — screenshots confirm both pages render the custom cover (overriding the real RAWG/Discogs art) and the populated input field, matching Book's UX exactly. --- .../Inventory/Pages/AlbumDetail.razor | 19 +++++- .../Inventory/Pages/VideoGameDetail.razor | 17 +++++- src/Domain/Models/AlbumModel.cs | 7 +++ src/Domain/Models/VideoGameModel.cs | 7 +++ src/Infrastructure.MongoDb/Entities/Album.cs | 3 + .../Entities/VideoGame.cs | 3 + src/WebApi.Contracts/Dto/AlbumDto.cs | 11 +++- src/WebApi.Contracts/Dto/VideoGameDto.cs | 11 +++- src/WebApi/Controllers/AlbumController.cs | 14 ++++- src/WebApi/Controllers/VideoGameController.cs | 14 ++++- .../Smoke/MobileScreenshotTest.cs | 30 +++++++++- .../Resources/AlbumResourceTest.cs | 60 ++++++++++++++++++- .../Resources/VideoGameResourceTest.cs | 52 ++++++++++++++++ 13 files changed, 229 insertions(+), 19 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor index 160159e7..c5f76947 100644 --- a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor @@ -50,10 +50,11 @@ else }
-
- @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) + @{ var coverUrl = !string.IsNullOrEmpty(Album.CustomImageUrl) ? Album.CustomImageUrl : Reference?.ImageUrl; } +
+ @if (!string.IsNullOrEmpty(coverUrl)) { - @Album.Title cover + @Album.Title cover }
@@ -74,6 +75,11 @@ else
+
+ + +
@@ -264,6 +270,13 @@ else await AlbumApi.UpdateAsync(Album); } + private async Task SaveCustomImageUrlAsync(ChangeEventArgs e) + { + if (Album is null) return; + Album.CustomImageUrl = e.Value?.ToString(); + await AlbumApi.UpdateAsync(Album); + } + private async Task RefreshReferenceAsync() { if (Album?.Id is null) return; diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 9623c5ec..8573f418 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -50,10 +50,11 @@ else } - @if (!string.IsNullOrEmpty(Reference?.ImageUrl)) + var coverUrl = !string.IsNullOrEmpty(Game.CustomImageUrl) ? Game.CustomImageUrl : Reference?.ImageUrl; + @if (!string.IsNullOrEmpty(coverUrl)) {
- @Game.Title cover + @Game.Title cover
} @@ -77,6 +78,11 @@ else {

@string.Join(", ", Reference.Genres)

} +
+ + +
@@ -285,6 +291,13 @@ else await SaveGameAsync(); } + private async Task SaveCustomImageUrlAsync(ChangeEventArgs e) + { + if (Game is null) return; + Game.CustomImageUrl = e.Value?.ToString(); + await SaveGameAsync(); + } + private void StartDraftPlatform() => _draftPlatform = new VideoGamePlatformDto(); private void CancelDraftPlatform() => _draftPlatform = null; diff --git a/src/Domain/Models/AlbumModel.cs b/src/Domain/Models/AlbumModel.cs index c4a57070..f93ebf88 100644 --- a/src/Domain/Models/AlbumModel.cs +++ b/src/Domain/Models/AlbumModel.cs @@ -21,6 +21,13 @@ public class AlbumModel : IHasIdAndOwnerId public string? ReferenceId { get; set; } + /// + /// Tenant-owned cover image override - takes priority over the linked reference's own cover wherever + /// a cover is shown (list thumbnail, detail page). Null means "use the reference's cover, if any" - + /// the previous, only behavior. + /// + public string? CustomImageUrl { get; set; } + public bool IsFavorite { get; set; } public List OwnedVersions { get; set; } = []; diff --git a/src/Domain/Models/VideoGameModel.cs b/src/Domain/Models/VideoGameModel.cs index cdb1a999..717612b7 100644 --- a/src/Domain/Models/VideoGameModel.cs +++ b/src/Domain/Models/VideoGameModel.cs @@ -21,6 +21,13 @@ public class VideoGameModel : IHasIdAndOwnerId public string? ReferenceId { get; set; } + /// + /// Tenant-owned cover image override - takes priority over the linked reference's own cover wherever + /// a cover is shown (list thumbnail, detail page). Null means "use the reference's cover, if any" - + /// the previous, only behavior. + /// + public string? CustomImageUrl { get; set; } + /// /// Filter-only: matches if is non-empty. Never persisted - a platform entry /// (with its own ) is this type's owned copy, so ownership derives from having diff --git a/src/Infrastructure.MongoDb/Entities/Album.cs b/src/Infrastructure.MongoDb/Entities/Album.cs index bef1f3ec..3306fe63 100644 --- a/src/Infrastructure.MongoDb/Entities/Album.cs +++ b/src/Infrastructure.MongoDb/Entities/Album.cs @@ -27,6 +27,9 @@ public class Album : IHasIdAndOwnerId [BsonElement("reference_id")] public string? ReferenceId { get; set; } + [BsonElement("custom_image_url")] + public string? CustomImageUrl { get; set; } + [BsonElement("is_favorite")] public bool IsFavorite { get; set; } diff --git a/src/Infrastructure.MongoDb/Entities/VideoGame.cs b/src/Infrastructure.MongoDb/Entities/VideoGame.cs index bbe239da..66b3c483 100644 --- a/src/Infrastructure.MongoDb/Entities/VideoGame.cs +++ b/src/Infrastructure.MongoDb/Entities/VideoGame.cs @@ -27,6 +27,9 @@ public class VideoGame : IHasIdAndOwnerId [BsonElement("reference_id")] public string? ReferenceId { get; set; } + [BsonElement("custom_image_url")] + public string? CustomImageUrl { get; set; } + [BsonElement("is_wishlisted")] public bool IsWishlisted { get; set; } } diff --git a/src/WebApi.Contracts/Dto/AlbumDto.cs b/src/WebApi.Contracts/Dto/AlbumDto.cs index f8a3e929..882de0cf 100644 --- a/src/WebApi.Contracts/Dto/AlbumDto.cs +++ b/src/WebApi.Contracts/Dto/AlbumDto.cs @@ -46,11 +46,18 @@ public class AlbumDto : IHasId, IReferenceLinkedDto public string? ReferenceId { get; set; } /// - /// Cover/poster image URL from the linked reference document - read-only, hydrated server-side on - /// list reads and never accepted from client input. + /// Cover image URL shown on the list page - when set, otherwise the linked + /// reference document's own cover. Read-only, hydrated server-side on list reads; never accepted from + /// client input (edit instead). /// public string? ImageUrl { get; set; } + /// + /// Tenant-owned cover image override, freely editable - takes priority over the linked reference's + /// cover wherever one is shown. Null means "use the reference's cover, if any". + /// + public string? CustomImageUrl { get; set; } + public bool IsFavorite { get; set; } /// diff --git a/src/WebApi.Contracts/Dto/VideoGameDto.cs b/src/WebApi.Contracts/Dto/VideoGameDto.cs index 10e1585c..f76141a9 100644 --- a/src/WebApi.Contracts/Dto/VideoGameDto.cs +++ b/src/WebApi.Contracts/Dto/VideoGameDto.cs @@ -35,11 +35,18 @@ public class VideoGameDto : IHasId, IReferenceLinkedDto public string? ReferenceId { get; set; } /// - /// Cover/poster image URL from the linked reference document - read-only, hydrated server-side on - /// list reads and never accepted from client input. + /// Cover image URL shown on the list page - when set, otherwise the linked + /// reference document's own cover. Read-only, hydrated server-side on list reads; never accepted from + /// client input (edit instead). /// public string? ImageUrl { get; set; } + /// + /// Tenant-owned cover image override, freely editable - takes priority over the linked reference's + /// cover wherever one is shown. Null means "use the reference's cover, if any". + /// + public string? CustomImageUrl { get; set; } + /// /// Filter-only query parameter: matches games with at least one platform entry (a game's copies). /// Never populated on a returned game - see for the convention. diff --git a/src/WebApi/Controllers/AlbumController.cs b/src/WebApi/Controllers/AlbumController.cs index 7c4e8364..8769b30a 100644 --- a/src/WebApi/Controllers/AlbumController.cs +++ b/src/WebApi/Controllers/AlbumController.cs @@ -21,10 +21,18 @@ public class AlbumController( { /// /// Hydrates each page item's cover image from its linked reference document - one batched lookup per - /// page (see ), keyed by the id-bearing documents only. + /// page (see ), keyed by the id-bearing documents only. An album with + /// its own set overrides that afterward - see + /// . /// - protected override Task OnListMappedAsync(List dtos) - => ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); + protected override async Task OnListMappedAsync(List dtos) + { + await ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); + foreach (var dto in dtos.Where(d => !string.IsNullOrEmpty(d.CustomImageUrl))) + { + dto.ImageUrl = dto.CustomImageUrl; + } + } /// /// Fires a best-effort background Discogs match for the new album - see . diff --git a/src/WebApi/Controllers/VideoGameController.cs b/src/WebApi/Controllers/VideoGameController.cs index b7d6d958..c9f7f4d0 100644 --- a/src/WebApi/Controllers/VideoGameController.cs +++ b/src/WebApi/Controllers/VideoGameController.cs @@ -21,10 +21,18 @@ public class VideoGameController( { /// /// Hydrates each page item's cover image from its linked reference document - one batched lookup per - /// page (see ), keyed by the id-bearing documents only. + /// page (see ), keyed by the id-bearing documents only. A game with + /// its own set overrides that afterward - see + /// . /// - protected override Task OnListMappedAsync(List dtos) - => ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); + protected override async Task OnListMappedAsync(List dtos) + { + await ReferenceImageHydrator.HydrateAsync(dtos, referenceRepository.FindByIdsAsync, x => x.ImageUrl); + foreach (var dto in dtos.Where(d => !string.IsNullOrEmpty(d.CustomImageUrl))) + { + dto.ImageUrl = dto.CustomImageUrl; + } + } /// /// Fires a best-effort background RAWG match for the new game - see . diff --git a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs index 1d8196d6..e460781f 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/MobileScreenshotTest.cs @@ -89,7 +89,10 @@ public async Task CaptureAllPagesAtPhoneViewport() await CaptureFirstDetailAsync("/cars", "car-detail"); await CaptureFirstDetailAsync("/books", "book-detail"); await CaptureFirstDetailAsync("/health", "health-detail"); - await CaptureFirstDetailAsync("/video-games", "video-game-detail"); + // targeted by title (not "first") so these two land on the CustomImageUrl-seeded items specifically, + // not whichever item happens to sort first in a list that grows over time + await CaptureDetailByTitleAsync("/video-games", "Hades", "video-game-detail"); + await CaptureDetailByTitleAsync("/albums", "Nevermind", "album-detail"); // The Add form modal on a list page. await Page.GotoAsync("/movies"); @@ -236,7 +239,10 @@ private static async Task SeedAsync(HttpClient api, List created) Artist = "Nirvana", Year = 1991, Rating = 4.5f, - IsFavorite = true + IsFavorite = true, + // proves the CustomImageUrl override shows up on both the list thumbnail and the detail cover, + // taking priority over the linked Discogs cover it's about to be linked to below + CustomImageUrl = "https://picsum.photos/seed/nevermind-custom-cover/400/400" }); await CreateAsync(api, created, "api/video-games", new VideoGameDto { @@ -244,7 +250,10 @@ private static async Task SeedAsync(HttpClient api, List created) Year = 2020, Rating = 4.5f, // a game's copies are its platform entries - there is no separate owned flag anymore - Platforms = [new VideoGamePlatformDto { Platform = "PC", CopyType = CopyType.Digital, State = "Current" }] + Platforms = [new VideoGamePlatformDto { Platform = "PC", CopyType = CopyType.Digital, State = "Current" }], + // proves the CustomImageUrl override shows up on both the list thumbnail and the detail cover, + // taking priority over the linked RAWG cover it's about to be linked to below + CustomImageUrl = "https://picsum.photos/seed/hades-custom-cover/600/300" }); await LinkFirstCandidateAsync(api, ReferenceItemType.Album, "Nevermind", 1991, "Nirvana"); await LinkFirstCandidateAsync(api, ReferenceItemType.VideoGame, "Hades", 2020, null); @@ -387,4 +396,19 @@ private async Task CaptureFirstDetailAsync(string listRoute, string name) await Page.WaitForTimeoutAsync(1500); await Page.ScreenshotAsync(new PageScreenshotOptions { Path = Path.Combine(ShotsDirectory, $"{name}.png"), FullPage = true }); } + + private async Task CaptureDetailByTitleAsync(string listRoute, string title, string name) + { + await Page.GotoAsync(listRoute); + await Page.WaitForTimeoutAsync(1200); + var itemLink = Page.Locator($"main a[href^='{listRoute}/']", new PageLocatorOptions { HasText = title }).First; + if (await itemLink.CountAsync() == 0) + { + return; + } + + await itemLink.ClickAsync(); + await Page.WaitForTimeoutAsync(1500); + await Page.ScreenshotAsync(new PageScreenshotOptions { Path = Path.Combine(ShotsDirectory, $"{name}.png"), FullPage = true }); + } } diff --git a/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs index e9f8112b..bb71c928 100644 --- a/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs @@ -1,11 +1,17 @@ +using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; using AwesomeAssertions; using Bogus; using Keeptrack.Common.System; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -28,7 +34,15 @@ public async Task AlbumResourceFullCycle_IsOk() await Authenticate(); var input = new Faker() - .Rules((f, o) => { o.Artist = f.Random.AlphaNumeric(8); o.Title = f.Random.AlphaNumeric(14); }) + .Rules((f, o) => + { + o.Artist = f.Random.AlphaNumeric(8); + o.Title = f.Random.AlphaNumeric(14); + // round-trips CustomImageUrl through the real Mapperly mappers + MongoDB - a plain scalar field + // with no special mapping, but a real integration test is what would catch a missed + // [BsonElement]/mapper ignore, not a mocked unit test. + o.CustomImageUrl = f.Internet.Url(); + }) .Generate(); var created = await PostAsync($"/{ResourceEndpoint}", input); created.Id.Should().NotBeNullOrEmpty(); @@ -127,4 +141,48 @@ public async Task AlbumResourceSearch_FiltersToMatchingTitleOrArtist_IsOk() await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); } } + + /// + /// follows 's exact + /// shape - AlbumController.OnListMappedAsync is expected to apply it over the linked reference's own + /// cover on every list read. + /// + [Fact] + public async Task AlbumResourceList_CustomImageUrlOverridesTheLinkedReferencesCover() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var uniqueTitle = $"CustomImageOverrideTarget-{Guid.NewGuid():N}"; + + var reference = await referenceRepository.UpsertAsync(new Keeptrack.Domain.Models.AlbumReferenceModel + { + Title = "Some Reference Title", + TitleNormalized = "some reference title", + ExternalIds = new Dictionary { ["discogs"] = $"discogs-{Guid.NewGuid():N}" }, + ImageUrl = "https://example.com/reference-cover.jpg" + }); + + await Authenticate(); + const string customImageUrl = "https://example.com/custom-cover.jpg"; + var created = await PostAsync($"/{ResourceEndpoint}", new AlbumDto + { + Title = uniqueTitle, + Artist = "Some Artist", + ReferenceId = reference.Id, + CustomImageUrl = customImageUrl + }); + + try + { + var list = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); + var item = list.Items.Should().ContainSingle(x => x.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("album_reference"); + await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); + } + } } diff --git a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs index 6d0b2af3..2c02a780 100644 --- a/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/VideoGameResourceTest.cs @@ -1,12 +1,17 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; using AwesomeAssertions; using Bogus; using Keeptrack.Common.System; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; using Keeptrack.WebApi.Contracts.Dto; using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; using Xunit; namespace Keeptrack.WebApi.IntegrationTests.Resources; @@ -32,6 +37,10 @@ public async Task VideoGameResourceFullCycle_IsOk() { o.Title = f.Random.AlphaNumeric(14); o.Platforms = [new VideoGamePlatformDto { Platform = "PC", CopyType = CopyType.Physical, State = "Current" }]; + // round-trips CustomImageUrl through the real Mapperly mappers + MongoDB - a plain scalar field + // with no special mapping, but a real integration test is what would catch a missed + // [BsonElement]/mapper ignore, not a mocked unit test. + o.CustomImageUrl = f.Internet.Url(); }) .Generate(); var created = await PostAsync($"/{ResourceEndpoint}", input); @@ -122,4 +131,47 @@ public async Task VideoGameResourceOwnedAndWishlistedFilters_OnlyReturnMatchingI await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); } } + + /// + /// follows 's exact shape - + /// VideoGameController.OnListMappedAsync is expected to apply it over the linked reference's own + /// cover on every list read. + /// + [Fact] + public async Task VideoGameResourceList_CustomImageUrlOverridesTheLinkedReferencesCover() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var uniqueTitle = $"CustomImageOverrideTarget-{Guid.NewGuid():N}"; + + var reference = await referenceRepository.UpsertAsync(new Keeptrack.Domain.Models.VideoGameReferenceModel + { + Title = "Some Reference Title", + TitleNormalized = "some reference title", + ExternalIds = new Dictionary { ["rawg"] = $"rawg-{Guid.NewGuid():N}" }, + ImageUrl = "https://example.com/reference-cover.jpg" + }); + + await Authenticate(); + const string customImageUrl = "https://example.com/custom-cover.jpg"; + var created = await PostAsync($"/{ResourceEndpoint}", new VideoGameDto + { + Title = uniqueTitle, + ReferenceId = reference.Id, + CustomImageUrl = customImageUrl + }); + + try + { + var list = await GetAsync>($"/{ResourceEndpoint}?search={uniqueTitle}"); + var item = list.Items.Should().ContainSingle(x => x.Id == created.Id).Subject; + item.ImageUrl.Should().Be(customImageUrl); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + var referenceCollection = scope.ServiceProvider.GetRequiredService().GetCollection("videogame_reference"); + await referenceCollection.DeleteOneAsync(Builders.Filter.Eq(x => x.Id, reference.Id), TestContext.Current.CancellationToken); + } + } } From 46f119f7496072ca6d76efa87c78c30c6e5e69e6 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 21 Jul 2026 02:18:50 +0200 Subject: [PATCH 08/35] Generic video game transaction import (PSN import) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What it does: a new "generic video game transaction import" (/import/video-games, POST /api/import/video-games/preview|commit) that parses a CSV (Transaction Date, Game Name, Product Name, Platform, Vendor, Transaction Id, Order Id, Final Price (€)) into review rows and commits selected ones as VideoGame items — same review-then-commit UX as the existing Amazon import, merging same-titled rows into one game with multiple Platforms entries. Key changes: - Shared engine extracted: the title-merge/dedup logic (ComputeCommitPlan/FindImportedReferences) was pulled out of AmazonImportMergeService into a new OwnedItemImportMergeService, since it was already fully generic. Amazon's own code/tests were only touched for the resulting rename, no behavior change. - New VideoGamePlatformModel.ProductName field (store's specific edition/product text), now editable on VideoGameDetail.razor via a new ExtraFields slot added to the shared OwnedVersionFields component. - Bug found and fixed: a PSN transaction can bundle several different products under one shared Transaction Id/Order Id (confirmed against your real export — three "Far Cry 4" DLC packs, one transaction). The dedup Reference was colliding across those lines and silently dropping platforms. Fixed by disambiguating with Product Name, mirroring how Amazon's import already uses ASIN for the same purpose. - Reconciliation reporting: RowsImported/SkippedRowTitles were added so the import result screen shows "X of Y selected rows imported" plus a named list of anything actually skipped, since the created/merged-item counts alone can look short even when nothing was lost (rows sharing a title consolidate into one item). --- CLAUDE.md | 42 +++ .../Import/GenericVideoGameImportApiClient.cs | 28 ++ .../Import/GenericVideoGameImportPage.razor | 274 ++++++++++++++++++ .../Components/Import/ImportPage.razor | 11 + .../Inventory/Pages/VideoGameDetail.razor | 16 +- .../Inventory/Shared/OwnedVersionFields.razor | 19 +- ...frastructureServiceCollectionExtensions.cs | 2 + src/Domain/Models/AmazonImportPlan.cs | 21 -- .../AmazonOwnedItemImportRequestItem.cs | 2 +- .../AmazonVideoGameImportRequestItem.cs | 2 +- .../GenericVideoGameImportPreviewRow.cs | 34 +++ .../GenericVideoGameImportRequestItem.cs | 21 ++ src/Domain/Models/ImportCommitPlan.cs | 34 +++ src/Domain/Models/VideoGamePlatformModel.cs | 7 + .../Services/AmazonImportMergeService.cs | 125 +------- .../Services/GenericVideoGameImportService.cs | 135 +++++++++ .../Services/OwnedItemImportMergeService.cs | 126 ++++++++ .../Entities/VideoGamePlatform.cs | 3 + .../GenericVideoGameImportCommitItemDto.cs | 59 ++++ .../GenericVideoGameImportCommitRequestDto.cs | 12 + .../GenericVideoGameImportCommitResultDto.cs | 32 ++ .../GenericVideoGameImportPreviewRowDto.cs | 48 +++ .../Dto/VideoGamePlatformDto.cs | 6 + .../Controllers/AmazonImportController.cs | 12 +- .../GenericVideoGameImportController.cs | 136 +++++++++ ...nericVideoGameImportPreviewRowDtoMapper.cs | 15 + src/WebApi/Program.cs | 1 + ...GenericVideoGameImportFixtureCsvBuilder.cs | 46 +++ .../GenericVideoGameImportResourceTest.cs | 180 ++++++++++++ .../Services/AmazonImportMergeServiceTest.cs | 218 +------------- .../GenericVideoGameImportServiceTest.cs | 120 ++++++++ .../OwnedItemImportMergeServiceTest.cs | 232 +++++++++++++++ 32 files changed, 1656 insertions(+), 363 deletions(-) create mode 100644 src/BlazorApp/Components/Import/GenericVideoGameImportApiClient.cs create mode 100644 src/BlazorApp/Components/Import/GenericVideoGameImportPage.razor delete mode 100644 src/Domain/Models/AmazonImportPlan.cs create mode 100644 src/Domain/Models/GenericVideoGameImportPreviewRow.cs create mode 100644 src/Domain/Models/GenericVideoGameImportRequestItem.cs create mode 100644 src/Domain/Models/ImportCommitPlan.cs create mode 100644 src/Domain/Services/GenericVideoGameImportService.cs create mode 100644 src/Domain/Services/OwnedItemImportMergeService.cs create mode 100644 src/WebApi.Contracts/Dto/GenericVideoGameImportCommitItemDto.cs create mode 100644 src/WebApi.Contracts/Dto/GenericVideoGameImportCommitRequestDto.cs create mode 100644 src/WebApi.Contracts/Dto/GenericVideoGameImportCommitResultDto.cs create mode 100644 src/WebApi.Contracts/Dto/GenericVideoGameImportPreviewRowDto.cs create mode 100644 src/WebApi/Controllers/GenericVideoGameImportController.cs create mode 100644 src/WebApi/Mappers/GenericVideoGameImportPreviewRowDtoMapper.cs create mode 100644 test/WebApi.IntegrationTests/Resources/GenericVideoGameImportFixtureCsvBuilder.cs create mode 100644 test/WebApi.IntegrationTests/Resources/GenericVideoGameImportResourceTest.cs create mode 100644 test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs create mode 100644 test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6963ee1f..6169c932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,6 +154,48 @@ Existing data needs the one-off `scripts/migrate-is-owned-to-owned-versions.js` then a `scripts/mongodb-create-index.js` re-run to replace the old `is_owned` index definitions. Covered by the resource tests' owned-filter cases (including a full decimal/date round-trip in `MovieResourceTest`/`AlbumResourceTest`) and `OwnershipSmokeTest` (Playwright, end-to-end through the editor). +`VideoGamePlatformModel.ProductName` (free text) is the store's own specific product/edition text for that copy (e.g. "Grand Theft Auto V : Édition Premium"), distinct from the game's own `Title` - +added for the generic video game transaction import below, but it's a plain field on the shared model/entity/DTO, editable on `VideoGameDetail.razor` like any other copy detail regardless of how the platform entry was created. +It renders via a new `ExtraFields` render-fragment slot on the shared `OwnedVersionFields` component (`Components/Inventory/Shared/`), not by adding it to `IOwnedCopyDto`: +Movie/TvShow/Book/Album's `OwnedVersionDto` has no equivalent concept, so the interface every `OwnedVersionFields` caller shares stays free of a field only one of them needs. +The component's own Price/Acquired/Vendor/Reference columns switched from a fixed `col-md-3` to an unnumbered `col-md` (Bootstrap's equal-width auto layout) for this - +with no `ExtraFields` supplied they still fill one row identically to the old fixed split, +but a 5th `ExtraFields` column (VideoGame's Product field) joins the same row and every column re-shares the width automatically instead of wrapping to a second row on desktop, +while `col-6` still stacks two-per-row on mobile like the others. + +### Bulk store/retailer transaction imports (Amazon, generic video game transactions) + +`AmazonImportController`/`AmazonOrderPreviewService` (preview an uploaded order-history CSV, let the user pick a type and edit fields per row, then commit) was the first of this shape, +followed by `GenericVideoGameImportController`/`GenericVideoGameImportService` for a video-game-only transaction-history CSV (PSN's own GDPR export today; the format isn't PlayStation-specific - +`Vendor` is a per-row column, not hardcoded - so any store exporting the same shape would work). +Both follow `WatchNextController`/`WishlistController`'s split (controller in `Controllers/`, pure parsing/computation in `Domain/Services/`), not the older `WebApi/Import/` feature-folder shape. +The create/merge/dedup engine behind both (`ComputeCommitPlan`/`FindImportedReferences`, matching by normalized title via `TitleNormalizer`, +merging within the same commit batch too) started out Amazon-only (`AmazonImportMergeService`) but was already fully generic - it moved to `Domain/Services/OwnedItemImportMergeService.cs` +(and its return type to `Domain/Models/ImportCommitPlan.cs`, renamed off `AmazonImportPlan`) once the video game importer needed the exact same engine, rather than duplicating it. +`AmazonImportMergeService` kept only what's genuinely Amazon-specific: `FormatOrderReference` (ASIN + order id) and `BuildAmazonProvenanceNotes`. + +**Gotcha, confirmed against a real PSN export:** a transaction/order id pair is *not* reliably unique per line the way Amazon's ASIN is. +A single PSN transaction can bundle several different products under one shared Transaction Id/Order Id (confirmed with a real export: +one transaction contained three separate "Far Cry 4" DLC packs, each its own CSV line, distinguishable only by Product Name). +`GenericVideoGameImportService.FormatReference` originally built the owned-copy `Reference` (and dedup key) from transaction id + order id alone, +the same class of bug `AmazonImportMergeService.FormatOrderReference` already avoids by including the ASIN - +without a per-product disambiguator, the second and third bundled lines collided with the first's reference and were silently skipped as "already imported" duplicates, +so only 1 of the 3 platform entries actually got created from a 3-line selection. +Fixed by appending the row's own Product Name (falling back to the title when blank) to the reference text, mirroring the ASIN's role for Amazon. +`GenericVideoGameImportPreviewRow.RowId` had the identical problem (plain `TransactionId`, colliding across the same bundled lines) and got the same fix. +Covered by `GenericVideoGameImportServiceTest.FormatReference_Disambiguates_WhenTwoLinesShareTheSameTransactionAndOrder` +and `GenericVideoGameImportResourceTest.PreviewThenCommit_ImportsAllThreeLines_WhenTheyShareOneTransactionAndOrderButDifferentProducts`. + +**`VideoGamesCreated`/`VideoGamesMergedInto` count distinct video games, not selected rows - this undercounts rows on purpose, not a bug.** +Several selected rows sharing a normalized title consolidate into one item within a single commit batch (`ComputeCommitPlan`'s existing same-batch-merge behavior), +which is correct but means `Created + MergedInto` can come out lower than the number of rows the user selected, +which read as a discrepancy on first use of the real 104-row PSN export (98 selected, "1 created, 0 merged, 2 already imported" summed to only 3 - actually the *bundling* bug above, +but the created/merged-vs-selected gap is real and independent of it). +`ImportCommitPlan.OwnedCopiesAdded`/`GenericVideoGameImportCommitResultDto.RowsImported` is the true per-row count (`RowsImported + VideoGamesSkipped` always equals the number of rows submitted), +and `SkippedRowTitles` lists exactly which selected rows were skipped as already-imported duplicates - +together they let `GenericVideoGameImportPage.razor` show a reconciling "X of Y selected rows imported" line plus a named list of anything actually skipped, +so the user can trust nothing was silently dropped instead of having to guess from the per-item counts alone. + ### Child entities (1-to-many owned by another entity) `CarHistory` (owned by `Car`) and `Episode` (owned by `TvShow`) are separate top-level collections referencing their parent by id (`car_id`, `tv_show_id`), not embedded arrays. diff --git a/src/BlazorApp/Components/Import/GenericVideoGameImportApiClient.cs b/src/BlazorApp/Components/Import/GenericVideoGameImportApiClient.cs new file mode 100644 index 00000000..ef544612 --- /dev/null +++ b/src/BlazorApp/Components/Import/GenericVideoGameImportApiClient.cs @@ -0,0 +1,28 @@ +using System.Net.Http.Headers; +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Import; + +public sealed class GenericVideoGameImportApiClient(HttpClient http) +{ + public async Task> PreviewAsync(Stream csvStream, string fileName) + { + using var content = new MultipartFormDataContent(); + using var fileContent = new StreamContent(csvStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/csv"); + content.Add(fileContent, "file", fileName); + + var response = await http.PostAsync("/api/import/video-games/preview", content); + response.EnsureSuccessStatusCode(); + + return (await response.Content.ReadFromJsonAsync>())!; + } + + public async Task CommitAsync(List items) + { + var response = await http.PostAsJsonAsync("/api/import/video-games/commit", new GenericVideoGameImportCommitRequestDto { Items = items }); + response.EnsureSuccessStatusCode(); + + return (await response.Content.ReadFromJsonAsync())!; + } +} diff --git a/src/BlazorApp/Components/Import/GenericVideoGameImportPage.razor b/src/BlazorApp/Components/Import/GenericVideoGameImportPage.razor new file mode 100644 index 00000000..b2c0d39f --- /dev/null +++ b/src/BlazorApp/Components/Import/GenericVideoGameImportPage.razor @@ -0,0 +1,274 @@ +@page "/import/video-games" +@attribute [Authorize] +@using Keeptrack.WebApi.Contracts.Dto +@using Keeptrack.BlazorApp.Components.Inventory.Pages + +
+

Import video game transactions

+
+ +
+

+ Upload a video game transaction-history CSV with columns: + Transaction Date, Game Name, Product Name, Platform, Vendor, Transaction Id, Order Id, Final Price (€). + This isn't store-specific - it works for any export shaped this way (e.g. a PlayStation Store GDPR export). + Nothing is imported until you click "Import selected" below. +

+ +
+ + +
+ + @if (_previewing) + { +

Reading your export…

+ } + + @if (_error is not null) + { +
@_error
+ } +
+ +@if (_rows is not null) +{ +
+
+
@DisplayedRows.Count() row(s) shown
+ +
+ +
+ + + + + + + + + + + + + @foreach (var row in DisplayedRows) + { + + + + + + + + + } + +
+ + TitleYearCopyPlatformOrder info
+ + @if (row.Preview.AlreadyImported) + { + + } + + + + + + @if (row.Preview.ProductName is not null) + { +
@row.Preview.ProductName
+ } + @if (row.Preview.TransactionDate is not null) + { +
@row.Preview.TransactionDate.Value.ToString("yyyy-MM-dd")
+ } + @if (row.Preview.Price is not null) + { +
@row.Preview.Price.Value.ToString("0.00") €
+ } +
+
+ + @if (SelectedRows.Any(r => string.IsNullOrWhiteSpace(r.Platform))) + { +

Pick a platform for every selected row before importing.

+ } + + +
+} + +@if (_commitError is not null) +{ +
@_commitError
+} + +@if (_commitResult is not null) +{ +
+

Video games: @_commitResult.VideoGamesCreated created, @_commitResult.VideoGamesMergedInto merged into existing, @_commitResult.VideoGamesSkipped already imported

+

+ @_commitResult.RowsImported of @_lastCommitRowCount selected row(s) imported. + @if (_commitResult.VideoGamesCreated + _commitResult.VideoGamesMergedInto < _commitResult.RowsImported) + { + Several rows shared a title and were combined into the same video game, so the created/merged count above is lower than the row count - nothing was lost. + } +

+ @if (_commitResult.SkippedRowTitles.Count > 0) + { +
+

Not imported (already imported before):

+
    + @foreach (var title in _commitResult.SkippedRowTitles) + { +
  • @title
  • + } +
+ } +
+} + +@code { + private const long MaxFileSize = 20_000_000; + + [Inject] private GenericVideoGameImportApiClient ImportApi { get; set; } = null!; + + private bool _previewing; + private string? _error; + private List? _rows; + + /// Defaults on - a re-uploaded export is mostly already-imported rows, and hiding them by + /// default is what actually keeps the review list short. + private bool _hideAlreadyImported = true; + + private bool _committing; + private string? _commitError; + private GenericVideoGameImportCommitResultDto? _commitResult; + + /// How many rows the last commit actually submitted - the reconciliation total shown next to + /// . + private int _lastCommitRowCount; + + private IEnumerable DisplayedRows => (_rows ?? []) + .Where(r => !_hideAlreadyImported || !r.Preview.AlreadyImported); + + private IEnumerable SelectedRows => _rows is null ? [] : _rows.Where(r => r.Selected); + + /// Drives the header checkbox: checked only once every currently-shown row is selected. + private bool AllDisplayedSelected => DisplayedRows.Any() && DisplayedRows.All(r => r.Selected); + + private void SetAllDisplayedSelected(bool selected) + { + foreach (var row in DisplayedRows) + { + row.Selected = selected; + } + } + + private async Task OnFileSelectedAsync(InputFileChangeEventArgs e) + { + _previewing = true; + _error = null; + _rows = null; + _commitResult = null; + _commitError = null; + + try + { +#pragma warning disable S5693 + await using var stream = e.File.OpenReadStream(MaxFileSize); +#pragma warning restore S5693 + var preview = await ImportApi.PreviewAsync(stream, e.File.Name); + _rows = preview.Select(ToEditableRow).ToList(); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _previewing = false; + } + } + + private async Task CommitAsync() + { + _committing = true; + _commitError = null; + _commitResult = null; + + try + { + var items = SelectedRows.Select(row => new GenericVideoGameImportCommitItemDto + { + RowId = row.Preview.RowId, + Title = row.Title, + // The original, unedited title from the parsed preview - kept separate from the editable + // row.Title above so it can be recorded verbatim in the created item's notes, regardless of + // whatever the user typed over it here. + SourceTitle = row.Preview.Title, + Platform = row.Platform, + ProductName = row.Preview.ProductName, + TransactionId = row.Preview.TransactionId, + OrderId = row.Preview.OrderId, + Vendor = row.Preview.Vendor, + Year = row.Year, + AcquiredAt = row.Preview.TransactionDate, + Price = row.Preview.Price, + CopyType = row.CopyType + }).ToList(); + + _lastCommitRowCount = items.Count; + _commitResult = await ImportApi.CommitAsync(items); + } + catch (Exception ex) + { + _commitError = ex.Message; + } + finally + { + _committing = false; + } + } + + private static EditableRow ToEditableRow(GenericVideoGameImportPreviewRowDto preview) => new() + { + Preview = preview, + Selected = !preview.AlreadyImported, + Title = preview.Title, + Platform = preview.Platform, + // This export is store purchase history, so a copy is inherently digital - still editable in case + // of an oddity (e.g. a listed physical bundle/voucher line). + CopyType = CopyType.Digital + }; + + private sealed class EditableRow + { + public required GenericVideoGameImportPreviewRowDto Preview { get; init; } + public bool Selected { get; set; } + + /// The one field worth editing at import time besides Year/Platform/Copy - everything else is read-only, order-derived info. + public string Title { get; set; } = ""; + + /// Never pre-filled - the export has no publication/release year. + public int? Year { get; set; } + + public string? Platform { get; set; } + + public CopyType CopyType { get; set; } + } +} diff --git a/src/BlazorApp/Components/Import/ImportPage.razor b/src/BlazorApp/Components/Import/ImportPage.razor index d932b8c4..d7d77a17 100644 --- a/src/BlazorApp/Components/Import/ImportPage.razor +++ b/src/BlazorApp/Components/Import/ImportPage.razor @@ -12,6 +12,17 @@ Import from Amazon
+
+

Import video game transactions

+
+ +
+

+ Review a video game store transaction history (e.g. a PlayStation Store GDPR export) and pick which items to import. +

+ Import video game transactions +
+

Import from TV Time

diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 8573f418..94500257 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -119,6 +119,13 @@ else + +
+ + +
+
@@ -348,7 +355,8 @@ else && entry.Price is null && entry.AcquiredAt is null && string.IsNullOrWhiteSpace(entry.Vendor) - && string.IsNullOrWhiteSpace(entry.Reference); + && string.IsNullOrWhiteSpace(entry.Reference) + && string.IsNullOrWhiteSpace(entry.ProductName); /// /// Clicking the already-active state button clears it back to unset - same toggle-off-on-reclick @@ -367,6 +375,12 @@ else await SaveGameAsync(); } + private async Task SetPlatformProductNameAsync(VideoGamePlatformDto entry, ChangeEventArgs e) + { + entry.ProductName = e.Value?.ToString(); + await SaveGameAsync(); + } + private async Task ToggleFullyCompletedAsync(VideoGamePlatformDto entry) { entry.IsFullyCompleted = !entry.IsFullyCompleted; diff --git a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor index e97c602f..cc11943e 100644 --- a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor +++ b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor @@ -5,7 +5,13 @@ Quick Add's own owned-copy toggle. Each caller keeps its own persistence timing (draft Save button vs. auto-save vs. nothing-persists-until-Save) via OnChanged, which defaults to a no-op. ButtonRowSuffix renders alongside the Physical/Digital buttons (e.g. a caller's own remove icon), so a - caller's header chrome doesn't need to be split awkwardly around this component. *@ + caller's header chrome doesn't need to be split awkwardly around this component. + ExtraFields renders as one more column in the same field row (e.g. VideoGamePlatformDto.ProductName, + which has no equivalent on the shared IOwnedCopyDto - not every caller has an extra field like this, so + this stays a slot rather than a member on the interface). The four columns below use unnumbered col-md + (equal-width auto layout) rather than a fixed col-md-3 for exactly this reason: with no ExtraFields they + still fill one row identically to a fixed 3/3/3/3 split, but a 5th ExtraFields column joins the same row + and everyone re-shares the width automatically, instead of wrapping to a second row. *@
@@ -16,26 +22,27 @@
@* the euro sign is a display choice, not stored data - a per-user currency setting may replace it later *@ -
+
-
+
-
+
-
+
+ @ExtraFields
@code { @@ -45,6 +52,8 @@ [Parameter] public RenderFragment? ButtonRowSuffix { get; set; } + [Parameter] public RenderFragment? ExtraFields { get; set; } + private Task SetCopyTypeAsync(CopyType copyType) { Copy.CopyType = copyType; diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 3e2ddb9e..273ea2e3 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -45,6 +45,8 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) diff --git a/src/Domain/Models/AmazonImportPlan.cs b/src/Domain/Models/AmazonImportPlan.cs deleted file mode 100644 index c3ca0c86..00000000 --- a/src/Domain/Models/AmazonImportPlan.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; - -namespace Keeptrack.Domain.Models; - -/// -/// What decided: -/// brand new items to create, and existing (or already-created-this-batch) items to update - each already -/// carrying the extra owned copy appended (an or a -/// , depending on ). -/// -public class AmazonImportPlan -{ - public List ItemsToCreate { get; set; } = []; - - public List ItemsToUpdate { get; set; } = []; - - public int OwnedCopiesAdded { get; set; } - - /// A row whose order reference already matched an existing owned copy - not duplicated. - public int OwnedCopiesSkipped { get; set; } -} diff --git a/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs b/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs index 4c4db97a..d11586f1 100644 --- a/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs +++ b/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs @@ -2,7 +2,7 @@ namespace Keeptrack.Domain.Models; /// /// One user-selected/edited row from the review UI, already translated from the web contract - the input -/// to for the three +/// to for the three /// domains that use (Book, Movie, TvShow). See /// for VideoGame's own shape. /// diff --git a/src/Domain/Models/AmazonVideoGameImportRequestItem.cs b/src/Domain/Models/AmazonVideoGameImportRequestItem.cs index 8028c5cb..1a53c3eb 100644 --- a/src/Domain/Models/AmazonVideoGameImportRequestItem.cs +++ b/src/Domain/Models/AmazonVideoGameImportRequestItem.cs @@ -1,7 +1,7 @@ namespace Keeptrack.Domain.Models; /// -/// VideoGame's own shape for - +/// VideoGame's own shape for - /// has no concept, using /// (with a required platform name Amazon's export can never supply) /// instead. See for Book/Movie/TvShow's shared shape. diff --git a/src/Domain/Models/GenericVideoGameImportPreviewRow.cs b/src/Domain/Models/GenericVideoGameImportPreviewRow.cs new file mode 100644 index 00000000..47f7638a --- /dev/null +++ b/src/Domain/Models/GenericVideoGameImportPreviewRow.cs @@ -0,0 +1,34 @@ +using System; + +namespace Keeptrack.Domain.Models; + +/// +/// One line item parsed from a generic video game transaction-history CSV (store purchase history - PSN +/// today, any store exporting the same shape later), for the user to review before commit. See +/// . +/// +public class GenericVideoGameImportPreviewRow +{ + /// The transaction id - unique per line item in every export seen so far. + public required string RowId { get; set; } + + /// The game title, cleaned of a trailing platform suffix (e.g. "(PS4)") when present. + public required string Title { get; set; } + + public required string Platform { get; set; } + + /// The store's own specific product/edition text (e.g. "Grand Theft Auto V : Édition Premium"). + public string? ProductName { get; set; } + + public required string Vendor { get; set; } + + public required string TransactionId { get; set; } + + public required string OrderId { get; set; } + + public DateOnly? TransactionDate { get; set; } + + public decimal? Price { get; set; } + + public bool AlreadyImported { get; set; } +} diff --git a/src/Domain/Models/GenericVideoGameImportRequestItem.cs b/src/Domain/Models/GenericVideoGameImportRequestItem.cs new file mode 100644 index 00000000..9ec655c2 --- /dev/null +++ b/src/Domain/Models/GenericVideoGameImportRequestItem.cs @@ -0,0 +1,21 @@ +namespace Keeptrack.Domain.Models; + +/// +/// One user-selected/edited row from the generic video game import review UI, already translated from the +/// web contract - the input to . +/// +public class GenericVideoGameImportRequestItem +{ + public required string Title { get; set; } + + /// + /// The title exactly as the source export listed it (before any edit the user made in the review UI) - + /// preserved in the created item's notes, since reference-data linking is expected to overwrite + /// later. + /// + public required string SourceTitle { get; set; } + + public int? Year { get; set; } + + public required VideoGamePlatformModel Platform { get; set; } +} diff --git a/src/Domain/Models/ImportCommitPlan.cs b/src/Domain/Models/ImportCommitPlan.cs new file mode 100644 index 00000000..52906464 --- /dev/null +++ b/src/Domain/Models/ImportCommitPlan.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Keeptrack.Domain.Models; + +/// +/// What decided: +/// brand new items to create, and existing (or already-created-this-batch) items to update - each already +/// carrying the extra owned copy appended (an or a +/// , depending on ). +/// +public class ImportCommitPlan +{ + public List ItemsToCreate { get; set; } = []; + + public List ItemsToUpdate { get; set; } = []; + + /// + /// Every row that successfully got an owned copy added - the true per-row count, unlike + /// /'s counts, which are per distinct item. + /// Several selected rows sharing a title can consolidate into a single newly-created item within one + /// commit batch, which is not a data loss (every one of those rows still got its owned copy), but does + /// make ItemsToCreate.Count + ItemsToUpdate.Count come out lower than the number of selected rows. + /// OwnedCopiesAdded + OwnedCopiesSkipped always equals the number of rows submitted - the + /// reconciling total a caller can show the user to prove nothing was silently dropped. + /// + public int OwnedCopiesAdded { get; set; } + + /// A row whose reference already matched an existing owned copy - not duplicated. + public int OwnedCopiesSkipped { get; set; } + + /// The title of each row counted in , in submission order - lets a + /// caller show the user exactly which selected rows were treated as already-imported duplicates. + public List SkippedTitles { get; set; } = []; +} diff --git a/src/Domain/Models/VideoGamePlatformModel.cs b/src/Domain/Models/VideoGamePlatformModel.cs index d517d9ce..35436678 100644 --- a/src/Domain/Models/VideoGamePlatformModel.cs +++ b/src/Domain/Models/VideoGamePlatformModel.cs @@ -47,4 +47,11 @@ public class VideoGamePlatformModel /// Unrelated to the reference-data ReferenceId concept. /// public string? Reference { get; set; } + + /// + /// The store's own specific product/edition text for this copy (e.g. "Grand Theft Auto V : Édition + /// Premium"), as opposed to the game's own . Populated by the generic + /// video game transaction import from its "Product Name" column. + /// + public string? ProductName { get; set; } } diff --git a/src/Domain/Services/AmazonImportMergeService.cs b/src/Domain/Services/AmazonImportMergeService.cs index 789272e2..27978c9f 100644 --- a/src/Domain/Services/AmazonImportMergeService.cs +++ b/src/Domain/Services/AmazonImportMergeService.cs @@ -1,135 +1,28 @@ -using System; using System.Collections.Generic; -using System.Linq; -using Keeptrack.Common.System; -using Keeptrack.Domain.Models; namespace Keeptrack.Domain.Services; /// -/// Computes what to create/update when committing a set of user-reviewed Amazon order rows, for any -/// trackable item type. Pure: takes the owner's already-fetched items and the selected rows, returns a plan - -/// all repository access stays in AmazonImportController. Matching is by normalized title -/// (, the same "same title" rule the TV Time import already established), -/// merging within the same commit batch too: two selected rows sharing a title become one item with two -/// owned copies, even if neither existed before this commit. -/// -/// Generic over both the tracked model (BookModel/MovieModel/TvShowModel/VideoGameModel) -/// and its own request-item shape (AmazonOwnedItemImportRequestItem for the first three, -/// AmazonVideoGameImportRequestItem for VideoGame) via delegates rather than a shared interface - -/// Book/Movie/TvShow's OwnedVersions and VideoGame's differently-shaped Platforms both flow -/// through the exact same algorithm this way, with no changes to any of those models. +/// Amazon-specific formatting used when committing a set of user-reviewed Amazon order rows: the ASIN-based +/// reference text and the provenance-notes text. The actual create/merge/dedup engine used to live here too, +/// but it was already fully generic - it moved to once the generic +/// video game transaction importer needed the exact same engine, leaving this class with only the two +/// members that are genuinely Amazon-specific. /// public static class AmazonImportMergeService { /// /// The one place that formats an owned copy's Reference for an imported order line - human-readable, - /// and also the exact-match dedup key looks for on a later - /// re-import. Includes the ASIN, not just the order id: a single Amazon order commonly contains several - /// different line items, and order-id-only matching (a real bug, found before this shipped) meant a - /// second, genuinely different item from the same order was silently skipped as a "duplicate" of the + /// and also the exact-match dedup key + /// looks for on a later re-import. Includes the ASIN, not just the order id: a single Amazon order commonly + /// contains several different line items, and order-id-only matching (a real bug, found before this shipped) + /// meant a second, genuinely different item from the same order was silently skipped as a "duplicate" of the /// first - or, at preview time, incorrectly flagged "already imported" just because a sibling item from /// the same order had been. The ASIN is Amazon's own stable per-product id, already parsed from every /// row, so it's a precise disambiguator - unlike the product title, which is user-editable before commit. /// public static string FormatOrderReference(string orderId, string asin) => $"Amazon order {orderId} (ASIN {asin})"; - /// - /// Every reference already recorded on an existing owned copy - used at preview time to flag rows that - /// look like they were imported before, so re-uploading a newer export doesn't duplicate them. Compared - /// by exact string equality against computed for a candidate row, - /// which is what makes the per-line-item (not per-order) precision above actually take effect. - /// reads whichever collection carries the owned copies for - /// (OwnedVersions or Platforms). - /// - public static HashSet FindImportedReferences(IEnumerable existingItems, Func> getReferences) => - existingItems.SelectMany(getReferences).Where(reference => reference is not null).ToHashSet()!; - - /// - /// / make the order - /// reference (see ) the *primary* dedup key, not just an advisory - /// preview-time flag: a re-committed row whose reference already exists on some existing item - under - /// any title, even one reference-data linking has since renamed - is skipped outright rather than - /// falling through to title matching. Two real bugs motivated this: (1) re-running the same commit - /// created a second owned copy for the same order every time, because title-matching alone doesn't - /// know "this exact copy is already here"; (2) once an item's title changed after a first import, - /// title-matching a re-import of the same order no longer found it at all and created a brand new - /// duplicate item instead. Title matching is still the fallback for a genuinely new order of an item - /// already owned under a different order (a second copy bought separately). - /// - public static AmazonImportPlan ComputeCommitPlan( - IReadOnlyCollection existingItems, - IReadOnlyList items, - Func getExistingTitle, - Func> getExistingReferences, - Func getItemTitle, - Func getItemReference, - Func createNew, - Action appendOwnedCopy) - where TModel : class - { - var plan = new AmazonImportPlan(); - - var byNormalizedTitle = new Dictionary(); - var byReference = new Dictionary(); - foreach (var existing in existingItems) - { - byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(getExistingTitle(existing)), existing); - foreach (var reference in getExistingReferences(existing)) - { - if (reference is not null) byReference.TryAdd(reference, existing); - } - } - - // Items created earlier in this same batch are tracked separately from pre-existing ones: a later - // row matching one must only get its owned copy appended (already reflected via ItemsToCreate), - // never also queued onto ItemsToUpdate - it has no Id yet, so "updating" it would be meaningless. - var createdThisBatch = new HashSet(); - - foreach (var item in items) - { - var reference = getItemReference(item); - if (reference is not null && byReference.ContainsKey(reference)) - { - plan.OwnedCopiesSkipped++; - continue; - } - - var key = TitleNormalizer.Normalize(getItemTitle(item)); - TModel target; - - if (byNormalizedTitle.TryGetValue(key, out var existing)) - { - appendOwnedCopy(existing, item); - if (!createdThisBatch.Contains(existing) && !plan.ItemsToUpdate.Contains(existing)) - { - plan.ItemsToUpdate.Add(existing); - } - - target = existing; - } - else - { - var created = createNew(item); - plan.ItemsToCreate.Add(created); - createdThisBatch.Add(created); - - // so a later row in the same batch sharing this title merges into it too, instead of - // creating a second item - byNormalizedTitle[key] = created; - target = created; - } - - // registers this reference immediately (not just from the initial existingItems scan), so a - // second row in the same batch carrying the same reference is caught by the check above too - if (reference is not null) byReference[reference] = target; - - plan.OwnedCopiesAdded++; - } - - return plan; - } - /// /// Reference-data linking is expected to overwrite the created item's title (and, for a book, its ISBN) /// with the provider's canonical values, and the user may have already cleaned up the title before diff --git a/src/Domain/Services/GenericVideoGameImportService.cs b/src/Domain/Services/GenericVideoGameImportService.cs new file mode 100644 index 00000000..7b314d8e --- /dev/null +++ b/src/Domain/Services/GenericVideoGameImportService.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using CsvHelper; +using CsvHelper.Configuration; +using CsvHelper.Configuration.Attributes; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Parses a generic video game transaction-history CSV (a store's purchase history export - PSN's own GDPR +/// export today, reshaped by the user into this shape; any store exporting the same columns would work too, +/// since is a per-row column rather than something hardcoded here) into review rows. +/// Pure: bytes in, rows out, no repository access - alreadyImportedReferences below is computed by +/// the caller from already-fetched data so this stays testable without a database. Every row is a video +/// game (unlike the Amazon import, which mixes several media types and needs a per-row type guess) - the +/// export is store-purchase history for games specifically. +/// +public static class GenericVideoGameImportService +{ + private sealed class VideoGameTransactionRecord + { + [Name("Transaction Date")] + public required string TransactionDate { get; set; } + + [Name("Game Name")] + public required string GameName { get; set; } + + [Name("Product Name")] + public string? ProductName { get; set; } + + [Name("Platform")] + public required string Platform { get; set; } + + [Name("Vendor")] + public required string Vendor { get; set; } + + [Name("Transaction Id")] + public required string TransactionId { get; set; } + + [Name("Order Id")] + public required string OrderId { get; set; } + + [Name("Final Price (€)")] + public string? FinalPrice { get; set; } + } + + private static readonly CsvConfiguration s_csvConfiguration = new(CultureInfo.InvariantCulture) + { + PrepareHeaderForMatch = args => args.Header.Trim().ToLowerInvariant() + }; + + public static List BuildPreview(Stream csvStream, IReadOnlySet alreadyImportedReferences) + { + using var reader = new StreamReader(csvStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + using var csv = new CsvReader(reader, s_csvConfiguration); + var records = csv.GetRecords().ToList(); + + return records.Select(record => new GenericVideoGameImportPreviewRow + { + // Transaction Id alone isn't unique per row (see FormatReference's doc comment - a bundled + // transaction has several lines sharing one), so RowId needs the same product disambiguator, + // same shape as AmazonOrderPreviewRow.RowId's "{OrderId}:{Asin}". + RowId = $"{record.TransactionId}:{record.ProductName ?? record.GameName}", + Title = CleanTitle(record.GameName, record.Platform), + Platform = record.Platform, + ProductName = record.ProductName, + Vendor = record.Vendor, + TransactionId = record.TransactionId, + OrderId = record.OrderId, + TransactionDate = ParseTransactionDate(record.TransactionDate), + Price = ParsePrice(record.FinalPrice), + AlreadyImported = alreadyImportedReferences.Contains(FormatReference(record.TransactionId, record.OrderId, record.ProductName, record.GameName)) + }).ToList(); + } + + /// + /// Strips a trailing " ({platform})" suffix from the game title (e.g. "Grand Theft Auto V (PS4)" with + /// platform "PS4" becomes "Grand Theft Auto V") - the export's own title column routinely repeats the + /// platform, redundant now that is its own + /// column. Falls back to the raw title unchanged when the suffix isn't present, so a source that doesn't + /// follow this convention is left alone rather than mangled. + /// + public static string CleanTitle(string gameName, string platform) + { + var suffix = $" ({platform})"; + return gameName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ? gameName[..^suffix.Length] : gameName; + } + + /// + /// The one place that formats an owned copy's Reference for an imported transaction line - + /// human-readable, and also the exact-match dedup key + /// looks for on a later + /// re-import. No vendor name here - already carries that. + /// + /// Appends (falling back to when blank) + /// because the transaction id + order id pair is *not* reliably unique per line: a single transaction + /// commonly bundles several different products together (confirmed against a real PSN export - one + /// order/transaction contained three separate "Far Cry 4" DLC packs, each its own line with a distinct + /// Product Name but the exact same Transaction Id/Order Id). Without this, two of those three lines' + /// references collided and were wrongly treated as re-imports of each other - the same class of bug + /// already avoids by including the ASIN + /// alongside the order id, since Amazon's export has a real stable per-product id to use for that; + /// this export has no such id, so the product/edition text is the next best disambiguator. + /// + public static string FormatReference(string transactionId, string orderId, string? productName, string fallbackTitle) + { + var descriptor = string.IsNullOrWhiteSpace(productName) ? fallbackTitle : productName; + return $"Order {orderId} (transaction {transactionId}) - {descriptor}"; + } + + /// + /// Reference-data linking is expected to overwrite the created item's title with the provider's + /// canonical value, and the user may have already cleaned up the title before commit - so this is the + /// one place the source export's original listing text is preserved, for an item created by this + /// import. Only used at creation time: a pre-existing item's provenance isn't this import's to invent. + /// + public static string BuildProvenanceNotes(string vendor, string sourceTitle) => $"Title from {vendor}: {sourceTitle}"; + + private static decimal? ParsePrice(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + + return decimal.TryParse(raw.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var value) ? value : null; + } + + private static DateOnly? ParseTransactionDate(string raw) => + DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) + ? DateOnly.FromDateTime(parsed.UtcDateTime) + : null; +} diff --git a/src/Domain/Services/OwnedItemImportMergeService.cs b/src/Domain/Services/OwnedItemImportMergeService.cs new file mode 100644 index 00000000..3f5c889b --- /dev/null +++ b/src/Domain/Services/OwnedItemImportMergeService.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Services; + +/// +/// Computes what to create/update when committing a set of user-reviewed import rows, for any trackable item +/// type. Pure: takes the owner's already-fetched items and the selected rows, returns a plan - all +/// repository access stays in the calling controller. Matching is by normalized title +/// (, the same "same title" rule the TV Time import already established), +/// merging within the same commit batch too: two selected rows sharing a title become one item with two +/// owned copies, even if neither existed before this commit. +/// +/// Generic over both the tracked model (BookModel/MovieModel/TvShowModel/VideoGameModel) +/// and its own request-item shape via delegates rather than a shared interface - Book/Movie/TvShow's +/// OwnedVersions and VideoGame's differently-shaped Platforms both flow through the exact same +/// algorithm this way, with no changes to any of those models. Extracted out of the originally Amazon-only +/// once a second importer (the generic video game transaction import) +/// needed the exact same engine - the two members here were already fully generic (no Amazon-specific +/// concept anywhere in their bodies), only and +/// are genuinely Amazon-specific and stayed +/// behind. +/// +public static class OwnedItemImportMergeService +{ + /// + /// Every reference already recorded on an existing owned copy - used at preview time to flag rows that + /// look like they were imported before, so re-uploading a newer export doesn't duplicate them. Compared + /// by exact string equality against whatever reference-formatting the calling importer uses for its own + /// candidate rows (e.g. ), which is what makes + /// per-line-item (not per-order) precision actually take effect. reads + /// whichever collection carries the owned copies for (OwnedVersions + /// or Platforms). + /// + public static HashSet FindImportedReferences(IEnumerable existingItems, Func> getReferences) => + existingItems.SelectMany(getReferences).Where(reference => reference is not null).ToHashSet()!; + + /// + /// / make the caller's own + /// reference (see ) the *primary* dedup key, not just an + /// advisory preview-time flag: a re-committed row whose reference already exists on some existing item - + /// under any title, even one reference-data linking has since renamed - is skipped outright rather than + /// falling through to title matching. Two real bugs (found on the original Amazon import) motivated this: + /// (1) re-running the same commit created a second owned copy for the same order every time, because + /// title-matching alone doesn't know "this exact copy is already here"; (2) once an item's title changed + /// after a first import, title-matching a re-import of the same order no longer found it at all and + /// created a brand new duplicate item instead. Title matching is still the fallback for a genuinely new + /// order of an item already owned under a different order (a second copy bought separately). + /// + public static ImportCommitPlan ComputeCommitPlan( + IReadOnlyCollection existingItems, + IReadOnlyList items, + Func getExistingTitle, + Func> getExistingReferences, + Func getItemTitle, + Func getItemReference, + Func createNew, + Action appendOwnedCopy) + where TModel : class + { + var plan = new ImportCommitPlan(); + + var byNormalizedTitle = new Dictionary(); + var byReference = new Dictionary(); + foreach (var existing in existingItems) + { + byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(getExistingTitle(existing)), existing); + foreach (var reference in getExistingReferences(existing)) + { + if (reference is not null) byReference.TryAdd(reference, existing); + } + } + + // Items created earlier in this same batch are tracked separately from pre-existing ones: a later + // row matching one must only get its owned copy appended (already reflected via ItemsToCreate), + // never also queued onto ItemsToUpdate - it has no Id yet, so "updating" it would be meaningless. + var createdThisBatch = new HashSet(); + + foreach (var item in items) + { + var reference = getItemReference(item); + if (reference is not null && byReference.ContainsKey(reference)) + { + plan.OwnedCopiesSkipped++; + plan.SkippedTitles.Add(getItemTitle(item)); + continue; + } + + var key = TitleNormalizer.Normalize(getItemTitle(item)); + TModel target; + + if (byNormalizedTitle.TryGetValue(key, out var existing)) + { + appendOwnedCopy(existing, item); + if (!createdThisBatch.Contains(existing) && !plan.ItemsToUpdate.Contains(existing)) + { + plan.ItemsToUpdate.Add(existing); + } + + target = existing; + } + else + { + var created = createNew(item); + plan.ItemsToCreate.Add(created); + createdThisBatch.Add(created); + + // so a later row in the same batch sharing this title merges into it too, instead of + // creating a second item + byNormalizedTitle[key] = created; + target = created; + } + + // registers this reference immediately (not just from the initial existingItems scan), so a + // second row in the same batch carrying the same reference is caught by the check above too + if (reference is not null) byReference[reference] = target; + + plan.OwnedCopiesAdded++; + } + + return plan; + } +} diff --git a/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs b/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs index c5abe624..ba65eaa3 100644 --- a/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs +++ b/src/Infrastructure.MongoDb/Entities/VideoGamePlatform.cs @@ -35,4 +35,7 @@ public class VideoGamePlatform public DateTime? AcquiredAt { get; set; } public string? Reference { get; set; } + + [BsonElement("product_name")] + public string? ProductName { get; set; } } diff --git a/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitItemDto.cs b/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitItemDto.cs new file mode 100644 index 00000000..2190e454 --- /dev/null +++ b/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitItemDto.cs @@ -0,0 +1,59 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One row selected for import, carrying whatever the user edited in the review table. +/// +public class GenericVideoGameImportCommitItemDto +{ + /// + /// The this came from. Not used for anything + /// server-side beyond echoing it back in error messages - the row's data is taken entirely from this + /// DTO's own fields. + /// + public required string RowId { get; set; } + + public required string Title { get; set; } + + /// + /// The title exactly as the source export listed it, even if was edited in the + /// review UI - recorded in the created item's notes, since reference-data linking is expected to + /// overwrite later. + /// + public required string SourceTitle { get; set; } + + /// + /// Required and validated server-side - the export always carries one, but a user-edited value could be + /// blanked out by mistake. + /// + public string? Platform { get; set; } + + /// The store's own specific product/edition text - see . + public string? ProductName { get; set; } + + /// + /// The storefront's own transaction id and order id - together they're the server-derived owned-copy + /// Reference (see GenericVideoGameImportService.FormatReference in the Domain project), + /// which also doubles as the exact dedup key. Both are echoed back from the preview row rather than + /// accepting a client-supplied Reference string directly, so the format can't drift out of sync + /// with what a later re-preview checks against. + /// + public required string TransactionId { get; set; } + + public required string OrderId { get; set; } + + public required string Vendor { get; set; } + + /// + /// Publication/release year, if the user happens to know it - the export has no source for this, so it's + /// never auto-filled. + /// + public int? Year { get; set; } + + public DateOnly? AcquiredAt { get; set; } + + public decimal? Price { get; set; } + + public CopyType CopyType { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitRequestDto.cs b/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitRequestDto.cs new file mode 100644 index 00000000..343ea569 --- /dev/null +++ b/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitRequestDto.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// The set of generic video game transaction rows the user picked in the review UI, ready to be created as +/// video games. +/// +public class GenericVideoGameImportCommitRequestDto +{ + public required List Items { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitResultDto.cs b/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitResultDto.cs new file mode 100644 index 00000000..9a6e54a3 --- /dev/null +++ b/src/WebApi.Contracts/Dto/GenericVideoGameImportCommitResultDto.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// Outcome of committing a selected set of generic video game transaction rows. +/// +public class GenericVideoGameImportCommitResultDto +{ + /// Brand new video games created. + public int VideoGamesCreated { get; set; } + + /// Existing video games (including ones created earlier in this same commit) that received an additional platform entry. + public int VideoGamesMergedInto { get; set; } + + /// Rows whose transaction reference already matched an existing platform entry - not duplicated. + public int VideoGamesSkipped { get; set; } + + /// + /// The true per-row count of rows that got a platform entry added, whether the row's own new video game + /// or an existing/already-created-this-batch one. / + /// count distinct video games, not rows - several selected rows sharing a title can consolidate into + /// one created video game, which makes those two counts add up to less than the number of rows selected + /// even though nothing was lost. RowsImported + VideoGamesSkipped always equals the number of + /// rows submitted - the reconciling total to show the user so they can trust nothing was silently dropped. + /// + public int RowsImported { get; set; } + + /// The title of each row counted in , so the user can see exactly + /// which selected rows were treated as already-imported duplicates. + public List SkippedRowTitles { get; set; } = []; +} diff --git a/src/WebApi.Contracts/Dto/GenericVideoGameImportPreviewRowDto.cs b/src/WebApi.Contracts/Dto/GenericVideoGameImportPreviewRowDto.cs new file mode 100644 index 00000000..6010b16a --- /dev/null +++ b/src/WebApi.Contracts/Dto/GenericVideoGameImportPreviewRowDto.cs @@ -0,0 +1,48 @@ +using System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// One line item parsed from an uploaded generic video game transaction-history export, awaiting the user's +/// review before anything is imported. See for what gets +/// sent back once selected. +/// +public class GenericVideoGameImportPreviewRowDto +{ + /// + /// Correlates a selected/edited row back to this one at commit time. Stable within one export, never stored. + /// + public required string RowId { get; set; } + + /// + /// The game title, cleaned of a trailing platform suffix (e.g. "(PS4)") when present. + /// + public required string Title { get; set; } + + public required string Platform { get; set; } + + /// + /// The store's own specific product/edition text (e.g. "Grand Theft Auto V : Édition Premium"). + /// + public string? ProductName { get; set; } + + /// + /// The storefront the transaction was made on (e.g. "PlayStation Store"). + /// + public required string Vendor { get; set; } + + public required string TransactionId { get; set; } + + public required string OrderId { get; set; } + + public DateOnly? TransactionDate { get; set; } + + public decimal? Price { get; set; } + + /// + /// True when an existing video game already has a platform entry referencing this transaction. Defaults + /// to unchecked/hidden in the review UI, but stays editable/selectable in case the user wants to + /// re-import anyway. + /// + public bool AlreadyImported { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs b/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs index 350fe40c..7a2b91fd 100644 --- a/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs +++ b/src/WebApi.Contracts/Dto/VideoGamePlatformDto.cs @@ -63,4 +63,10 @@ public class VideoGamePlatformDto : IOwnedCopyDto /// Free-text reference for this copy: edition name, order number, barcode... /// public string? Reference { get; set; } + + /// + /// The store's own specific product/edition text for this copy (e.g. "Grand Theft Auto V : Édition Premium"), + /// as opposed to the game's own title. + /// + public string? ProductName { get; set; } } diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs index 56c4343e..131f8139 100644 --- a/src/WebApi/Controllers/AmazonImportController.cs +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -55,10 +55,10 @@ public async Task>> Preview(IFormFil var existingVideoGames = await FindAllAsync(videoGameRepository, ownerId, new VideoGameModel { OwnerId = ownerId, Title = string.Empty }); var alreadyImportedReferences = new HashSet(); - alreadyImportedReferences.UnionWith(AmazonImportMergeService.FindImportedReferences(existingBooks, b => b.OwnedVersions.Select(v => v.Reference))); - alreadyImportedReferences.UnionWith(AmazonImportMergeService.FindImportedReferences(existingMovies, m => m.OwnedVersions.Select(v => v.Reference))); - alreadyImportedReferences.UnionWith(AmazonImportMergeService.FindImportedReferences(existingTvShows, t => t.OwnedVersions.Select(v => v.Reference))); - alreadyImportedReferences.UnionWith(AmazonImportMergeService.FindImportedReferences(existingVideoGames, g => g.Platforms.Select(p => p.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingBooks, b => b.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingMovies, m => m.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingTvShows, t => t.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingVideoGames, g => g.Platforms.Select(p => p.Reference))); await using var stream = file.OpenReadStream(); var rows = AmazonOrderPreviewService.BuildPreview(stream, alreadyImportedReferences); @@ -70,7 +70,7 @@ public async Task>> Preview(IFormFil /// Creates/updates items from the rows the user selected in the review UI, grouped by the media type /// each row was assigned. A row whose (normalized) title matches an existing item of the same type - or /// one created earlier in this same request - gets an additional owned copy instead of a duplicate - /// item; see . + /// item; see . ///
[HttpPost("commit")] [ProducesResponseType(200)] @@ -228,7 +228,7 @@ private static Keeptrack.Domain.Models.CopyType ToDomainCopyType(Keeptrack.WebAp string ownerId) where TModel : class, IHasIdAndOwnerId { - var plan = AmazonImportMergeService.ComputeCommitPlan( + var plan = OwnedItemImportMergeService.ComputeCommitPlan( existingItems, requestItems, getExistingTitle, getExistingReferences, getItemTitle, getItemReference, createNew, appendOwnedCopy); foreach (var item in plan.ItemsToCreate) diff --git a/src/WebApi/Controllers/GenericVideoGameImportController.cs b/src/WebApi/Controllers/GenericVideoGameImportController.cs new file mode 100644 index 00000000..c63ff3a3 --- /dev/null +++ b/src/WebApi/Controllers/GenericVideoGameImportController.cs @@ -0,0 +1,136 @@ +using System.Diagnostics.CodeAnalysis; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Domain.Services; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +/// +/// Previews a generic video game transaction-history export and commits the rows the user selected/edited in +/// the review UI as video games. Unlike , every row is always a video +/// game (the export is store purchase history for games specifically), so there's no per-row media-type +/// picker and no generic multi-type commit branching - just one direct call into the shared +/// engine. +/// +[ApiController] +[Authorize(Policy = "MemberOnly")] +[Route("api/import/video-games")] +public class GenericVideoGameImportController( + IVideoGameRepository videoGameRepository, + GenericVideoGameImportPreviewRowDtoMapper previewMapper) : ControllerBase +{ + /// + /// Parses the uploaded transaction-history CSV and returns every line item for review - nothing is + /// persisted by this call. + /// + [HttpPost("preview")] + [RequestSizeLimit(20_000_000)] + [Consumes("multipart/form-data")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + [SuppressMessage("Security", "S5693:Make sure the content length limit is safe here", + Justification = "The limit IS set (20 MB), deliberately above Sonar's 8 MB default: a multi-year " + + "transaction-history export can be sizeable, and the endpoint is authenticated, member-only, admin-of-your-own-data.")] + public async Task>> Preview(IFormFile file) + { + if (file.Length == 0) + { + return BadRequest(); + } + + var ownerId = this.GetUserId(); + + var existingVideoGames = await FindAllAsync(ownerId); + var alreadyImportedReferences = OwnedItemImportMergeService.FindImportedReferences(existingVideoGames, g => g.Platforms.Select(p => p.Reference)); + + await using var stream = file.OpenReadStream(); + var rows = GenericVideoGameImportService.BuildPreview(stream, alreadyImportedReferences); + + return Ok(rows.Select(previewMapper.ToDto).ToList()); + } + + /// + /// Creates/updates video games from the rows the user selected in the review UI. A row whose (normalized) + /// title matches an existing video game - or one created earlier in this same request - gets an + /// additional platform entry instead of a duplicate item; see + /// . + /// + [HttpPost("commit")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task> Commit(GenericVideoGameImportCommitRequestDto request) + { + var ownerId = this.GetUserId(); + + foreach (var item in request.Items.Where(item => string.IsNullOrWhiteSpace(item.Platform))) + { + throw new ArgumentException($"A platform is required to import '{item.Title}'."); + } + + var existingVideoGames = await FindAllAsync(ownerId); + var requestItems = request.Items.Select(ToRequestItem).ToList(); + + var plan = OwnedItemImportMergeService.ComputeCommitPlan( + existingVideoGames, requestItems, + g => g.Title, g => g.Platforms.Select(p => p.Reference), + i => i.Title, i => i.Platform.Reference, + item => new VideoGameModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = GenericVideoGameImportService.BuildProvenanceNotes(item.Platform.Vendor!, item.SourceTitle), + Platforms = [item.Platform] + }, + (game, item) => game.Platforms.Add(item.Platform)); + + foreach (var item in plan.ItemsToCreate) + { + await videoGameRepository.CreateAsync(item); + } + + foreach (var item in plan.ItemsToUpdate) + { + await videoGameRepository.UpdateAsync(item.Id!, item, ownerId); + } + + return Ok(new GenericVideoGameImportCommitResultDto + { + VideoGamesCreated = plan.ItemsToCreate.Count, + VideoGamesMergedInto = plan.ItemsToUpdate.Count, + VideoGamesSkipped = plan.OwnedCopiesSkipped, + RowsImported = plan.OwnedCopiesAdded, + SkippedRowTitles = plan.SkippedTitles + }); + } + + private static GenericVideoGameImportRequestItem ToRequestItem(GenericVideoGameImportCommitItemDto item) => new() + { + Title = item.Title, + SourceTitle = item.SourceTitle, + Year = item.Year, + Platform = new VideoGamePlatformModel + { + Platform = item.Platform!, + CopyType = ToDomainCopyType(item.CopyType), + ProductName = item.ProductName, + Price = item.Price, + Vendor = item.Vendor, + AcquiredAt = item.AcquiredAt, + // Derived server-side from the transaction id + order id + product name the preview row + // reported, never from a client-supplied Reference string - the product name is what + // disambiguates two different items sharing one transaction/order (a single transaction can + // bundle several products), and the whole string doubles as the exact-match dedup key on a re-import. + Reference = GenericVideoGameImportService.FormatReference(item.TransactionId, item.OrderId, item.ProductName, item.SourceTitle) + } + }; + + private static Keeptrack.Domain.Models.CopyType ToDomainCopyType(Keeptrack.WebApi.Contracts.Dto.CopyType copyType) => + Enum.Parse(copyType.ToString()); + + private async Task> FindAllAsync(string ownerId) => + (await videoGameRepository.FindAllAsync(ownerId, 1, int.MaxValue, null, new VideoGameModel { OwnerId = ownerId, Title = string.Empty })).Items; +} diff --git a/src/WebApi/Mappers/GenericVideoGameImportPreviewRowDtoMapper.cs b/src/WebApi/Mappers/GenericVideoGameImportPreviewRowDtoMapper.cs new file mode 100644 index 00000000..b51c8e12 --- /dev/null +++ b/src/WebApi/Mappers/GenericVideoGameImportPreviewRowDtoMapper.cs @@ -0,0 +1,15 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// One-directional (Model -> Dto): is pure and +/// knows nothing about the web contract, so maps +/// its rows here - same shape as . +/// +[Mapper] +public partial class GenericVideoGameImportPreviewRowDtoMapper +{ + public partial GenericVideoGameImportPreviewRowDto ToDto(GenericVideoGameImportPreviewRow model); +} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index c9727cf9..e65d903e 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -28,6 +28,7 @@ builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.VideoGameDtoMapper>(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportFixtureCsvBuilder.cs b/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportFixtureCsvBuilder.cs new file mode 100644 index 00000000..de5d3e8f --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportFixtureCsvBuilder.cs @@ -0,0 +1,46 @@ +using System.Text; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Builds a small, synthetic video game transaction-history CSV for tests - never use a real personal +/// export as a test fixture (same rule as ). +/// +internal static class GenericVideoGameImportFixtureCsvBuilder +{ + public const string GameTitle = "Keeptrack Video Game Import Test Game"; + public const string GamePlatform = "PS4"; + public const string GameProductName = "Keeptrack Video Game Import Test Game : Premium Edition"; + public const string GameVendor = "PlayStation Store"; + public const string GameTransactionId = "999888777001"; + public const string GameOrderId = "999888777002"; + + public const string SecondGameTitle = "Keeptrack Video Game Import Test Game Two"; + private const string SecondGameTransactionId = "999888777003"; + private const string SecondGameOrderId = "999888777004"; + + /// + /// Reproduces a real PSN export shape: a single order bundled three different DLC packs for the same + /// game under one shared Transaction Id/Order Id, distinguishable only by Product Name. + /// + public const string BundleTitle = "Keeptrack Video Game Import Test Bundle Game"; + private const string BundleTransactionId = "999888777005"; + private const string BundleOrderId = "999888777006"; + public const string BundleProductA = "Keeptrack Video Game Import Test Bundle Game - DLC A"; + public const string BundleProductB = "Keeptrack Video Game Import Test Bundle Game - DLC B"; + public const string BundleProductC = "Keeptrack Video Game Import Test Bundle Game - DLC C"; + + public static byte[] Build() + { + var csv = $""" + Transaction Date,Game Name,Product Name,Platform,Vendor,Transaction Id,Order Id,Final Price (€) + 2019-08-02,{GameTitle} ({GamePlatform}),{GameProductName},{GamePlatform},{GameVendor},{GameTransactionId},{GameOrderId},14.99 + 2021-03-15,{SecondGameTitle},{SecondGameTitle},PS5,{GameVendor},{SecondGameTransactionId},{SecondGameOrderId},59.99 + 2025-11-21,{BundleTitle},{BundleProductA},PS4,{GameVendor},{BundleTransactionId},{BundleOrderId},2.24 + 2025-11-21,{BundleTitle},{BundleProductB},PS4,{GameVendor},{BundleTransactionId},{BundleOrderId},1.12 + 2025-11-21,{BundleTitle},{BundleProductC},PS4,{GameVendor},{BundleTransactionId},{BundleOrderId},1.49 + + """; + return Encoding.UTF8.GetBytes(csv); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportResourceTest.cs new file mode 100644 index 00000000..088f67f8 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/GenericVideoGameImportResourceTest.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +public class GenericVideoGameImportResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + [Fact] + public async Task PreviewThenCommit_CreatesAVideoGame_AndSkipsADuplicateReimport() + { + await Authenticate(); + + var csv = GenericVideoGameImportFixtureCsvBuilder.Build(); + + var preview = await PostFileAsync>("/api/import/video-games/preview", "file", csv, "transactions.csv"); + var gameRow = preview.Should().Contain(r => r.Title == GenericVideoGameImportFixtureCsvBuilder.GameTitle).Subject; + gameRow.Platform.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GamePlatform); + gameRow.ProductName.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GameProductName); + gameRow.AlreadyImported.Should().BeFalse(); + + try + { + // a row with no platform must be rejected before anything is persisted + var invalidItem = ToCommitItem(gameRow); + invalidItem.Platform = null; + var invalidRequest = new GenericVideoGameImportCommitRequestDto { Items = [invalidItem] }; + await PostAsync("/api/import/video-games/commit", invalidRequest, HttpStatusCode.BadRequest); + + var commitRequest = new GenericVideoGameImportCommitRequestDto { Items = [ToCommitItem(gameRow)] }; + var commitResult = await PostAsync("/api/import/video-games/commit", commitRequest); + commitResult.VideoGamesCreated.Should().Be(1); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.GameTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().ContainSingle(); + videoGame.Platforms[0].Platform.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GamePlatform); + videoGame.Platforms[0].ProductName.Should().Be(GenericVideoGameImportFixtureCsvBuilder.GameProductName); + videoGame.Platforms[0].CopyType.Should().Be(CopyType.Digital); + videoGame.Platforms[0].Price.Should().Be(14.99m); + videoGame.Platforms[0].Reference.Should().Contain(GenericVideoGameImportFixtureCsvBuilder.GameTransactionId); + // SourceTitle is echoed from the preview row's already-cleaned Title (platform suffix already + // stripped by CleanTitle during parsing), not the raw "Game Name (PS4)" CSV cell. + videoGame.Notes.Should().Be($"Title from {GenericVideoGameImportFixtureCsvBuilder.GameVendor}: {GenericVideoGameImportFixtureCsvBuilder.GameTitle}"); + + // re-preview after commit: the just-imported transaction must now be flagged, so re-uploading a + // newer export later doesn't silently duplicate it + var secondPreview = await PostFileAsync>("/api/import/video-games/preview", "file", csv, "transactions.csv"); + secondPreview.Should().Contain(r => r.Title == GenericVideoGameImportFixtureCsvBuilder.GameTitle && r.AlreadyImported); + + // committing the exact same row again must not duplicate anything + var secondCommitResult = await PostAsync("/api/import/video-games/commit", commitRequest); + secondCommitResult.VideoGamesCreated.Should().Be(0); + secondCommitResult.VideoGamesMergedInto.Should().Be(0); + secondCommitResult.VideoGamesSkipped.Should().Be(1); + + var videoGamesAfterReimport = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.GameTitle)}"); + videoGamesAfterReimport.Items.Should().ContainSingle().Which.Platforms.Should().ContainSingle(); + } + finally + { + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.GameTitle)}"); + foreach (var videoGame in videoGames.Items.Where(g => g.Id is not null)) + { + await DeleteAsync($"/api/video-games/{videoGame.Id}"); + } + } + } + + [Fact] + public async Task Commit_MergesTwoRowsSharingATitle_IntoOneVideoGameWithTwoPlatforms() + { + await Authenticate(); + + const string sharedTitle = "Keeptrack Video Game Import Test Multi-Platform Game"; + + var request = new GenericVideoGameImportCommitRequestDto + { + Items = + [ + new GenericVideoGameImportCommitItemDto + { + RowId = "row-1", Title = sharedTitle, SourceTitle = sharedTitle, Platform = "PS4", + TransactionId = "111222333001", OrderId = "111222333002", Vendor = "PlayStation Store", CopyType = CopyType.Digital + }, + new GenericVideoGameImportCommitItemDto + { + RowId = "row-2", Title = sharedTitle, SourceTitle = sharedTitle, Platform = "PS5", + TransactionId = "111222333003", OrderId = "111222333004", Vendor = "PlayStation Store", CopyType = CopyType.Digital + } + ] + }; + + try + { + var commitResult = await PostAsync("/api/import/video-games/commit", request); + commitResult.VideoGamesCreated.Should().Be(1); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(sharedTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().HaveCount(2); + videoGame.Platforms.Select(p => p.Platform).Should().BeEquivalentTo(["PS4", "PS5"]); + } + finally + { + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(sharedTitle)}"); + foreach (var videoGame in videoGames.Items.Where(g => g.Id is not null)) + { + await DeleteAsync($"/api/video-games/{videoGame.Id}"); + } + } + } + + [Fact] + public async Task PreviewThenCommit_ImportsAllThreeLines_WhenTheyShareOneTransactionAndOrderButDifferentProducts() + { + // reproduces a real bug found against a real PSN export: three different "Far Cry 4" DLC packs were + // bought in a single transaction, sharing one Transaction Id/Order Id - the Reference used to be + // built from those two alone, so the second and third lines were wrongly skipped as "already + // imported" duplicates of the first, and only 1 of the 3 platform entries actually got created + await Authenticate(); + + var csv = GenericVideoGameImportFixtureCsvBuilder.Build(); + + try + { + var preview = await PostFileAsync>("/api/import/video-games/preview", "file", csv, "transactions.csv"); + var bundleRows = preview.Where(r => r.Title == GenericVideoGameImportFixtureCsvBuilder.BundleTitle).ToList(); + bundleRows.Should().HaveCount(3); + bundleRows.Should().OnlyContain(r => !r.AlreadyImported); + + var commitRequest = new GenericVideoGameImportCommitRequestDto { Items = bundleRows.Select(ToCommitItem).ToList() }; + var commitResult = await PostAsync("/api/import/video-games/commit", commitRequest); + commitResult.VideoGamesCreated.Should().Be(1); + commitResult.VideoGamesSkipped.Should().Be(0); + + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.BundleTitle)}"); + var videoGame = videoGames.Items.Should().ContainSingle().Subject; + videoGame.Platforms.Should().HaveCount(3); + videoGame.Platforms.Select(p => p.ProductName).Should().BeEquivalentTo( + [ + GenericVideoGameImportFixtureCsvBuilder.BundleProductA, + GenericVideoGameImportFixtureCsvBuilder.BundleProductB, + GenericVideoGameImportFixtureCsvBuilder.BundleProductC + ]); + videoGame.Platforms.Select(p => p.Reference).Distinct().Should().HaveCount(3); + } + finally + { + var videoGames = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(GenericVideoGameImportFixtureCsvBuilder.BundleTitle)}"); + foreach (var videoGame in videoGames.Items.Where(g => g.Id is not null)) + { + await DeleteAsync($"/api/video-games/{videoGame.Id}"); + } + } + } + + private static GenericVideoGameImportCommitItemDto ToCommitItem(GenericVideoGameImportPreviewRowDto row) => new() + { + RowId = row.RowId, + Title = row.Title, + SourceTitle = row.Title, + Platform = row.Platform, + ProductName = row.ProductName, + TransactionId = row.TransactionId, + OrderId = row.OrderId, + Vendor = row.Vendor, + AcquiredAt = row.TransactionDate, + Price = row.Price, + CopyType = CopyType.Digital + }; +} diff --git a/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs b/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs index 0a78547f..e47f47a3 100644 --- a/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs +++ b/test/WebApi.UnitTests/Services/AmazonImportMergeServiceTest.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Linq; using AwesomeAssertions; -using Keeptrack.Domain.Models; using Keeptrack.Domain.Services; using Xunit; @@ -10,196 +7,9 @@ namespace Keeptrack.WebApi.UnitTests.Services; [Trait("Category", "UnitTests")] public class AmazonImportMergeServiceTest { - private const string OwnerId = "owner-1"; private const string SomeOrderId = "405-1111111-1111111"; private const string SomeAsin = "0552177571"; - private static BookModel Book(string title, params OwnedVersionModel[] ownedVersions) => - new() { Id = title, OwnerId = OwnerId, Title = title, Author = string.Empty, OwnedVersions = [.. ownedVersions] }; - - private static VideoGameModel Game(string title, params VideoGamePlatformModel[] platforms) => - new() { Id = title, OwnerId = OwnerId, Title = title, Platforms = [.. platforms] }; - - private static AmazonOwnedItemImportRequestItem Item(string title, string? reference = null, string? amazonTitle = null, string? isbn = null) => new() - { - Title = title, - AmazonTitle = amazonTitle ?? title, - Isbn = isbn, - OwnedVersion = new OwnedVersionModel { Reference = reference } - }; - - /// Wires up the generic engine for BookModel, the same way AmazonImportController does. - private static AmazonImportPlan ComputeBookPlan(IReadOnlyCollection existing, IReadOnlyList items) => - AmazonImportMergeService.ComputeCommitPlan( - existing, items, - b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), - i => i.Title, i => i.OwnedVersion.Reference, - item => new BookModel - { - OwnerId = OwnerId, - Title = item.Title, - Author = string.Empty, - Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), - OwnedVersions = [item.OwnedVersion] - }, - (book, item) => book.OwnedVersions.Add(item.OwnedVersion)); - - [Fact] - public void ComputeCommitPlan_CreatesANewBook_WhenNoExistingBookMatchesTheTitle() - { - var plan = ComputeBookPlan([], [Item("The Secret")]); - - plan.ItemsToCreate.Should().ContainSingle(); - plan.ItemsToCreate[0].Title.Should().Be("The Secret"); - plan.ItemsToCreate[0].Author.Should().Be(string.Empty); - plan.ItemsToCreate[0].OwnedVersions.Should().ContainSingle(); - plan.ItemsToUpdate.Should().BeEmpty(); - plan.OwnedCopiesAdded.Should().Be(1); - } - - [Fact] - public void ComputeCommitPlan_RecordsAmazonsOriginalTitleAndIsbnInNotes_ForANewlyCreatedBook() - { - var item = Item("The Secret", amazonTitle: "The Secret: Jack Reacher, Book 28", isbn: "0552177571"); - - var plan = ComputeBookPlan([], [item]); - - plan.ItemsToCreate[0].Notes.Should().Be("Title from Amazon: The Secret: Jack Reacher, Book 28\nISBN from Amazon: 0552177571"); - } - - [Fact] - public void ComputeCommitPlan_OmitsTheIsbnNoteLine_WhenThereIsNoIsbn() - { - var item = Item("A Book With No Isbn", amazonTitle: "A Book With No Isbn"); - - var plan = ComputeBookPlan([], [item]); - - plan.ItemsToCreate[0].Notes.Should().Be("Title from Amazon: A Book With No Isbn"); - } - - [Fact] - public void ComputeCommitPlan_DoesNotTouchNotes_WhenMergingIntoAnExistingBook() - { - var existing = Book("Some Book"); - existing.Notes = "My own pre-existing notes"; - - var plan = ComputeBookPlan([existing], [Item("some book", amazonTitle: "Some Book (Amazon listing)")]); - - plan.ItemsToCreate.Should().BeEmpty(); - existing.Notes.Should().Be("My own pre-existing notes"); - } - - [Fact] - public void ComputeCommitPlan_MergesIntoAnExistingBook_WhenTheNormalizedTitleMatches() - { - var existing = Book(" Some Book "); - - var plan = ComputeBookPlan([existing], [Item("some book")]); - - plan.ItemsToCreate.Should().BeEmpty(); - plan.ItemsToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); - existing.OwnedVersions.Should().ContainSingle(); - plan.OwnedCopiesAdded.Should().Be(1); - } - - [Fact] - public void ComputeCommitPlan_MergesTwoSelectedRowsSharingATitle_IntoOneNewBook() - { - var plan = ComputeBookPlan([], [Item("Duplicate Title"), Item("duplicate title")]); - - plan.ItemsToCreate.Should().ContainSingle(); - plan.ItemsToCreate[0].OwnedVersions.Should().HaveCount(2); - plan.ItemsToUpdate.Should().BeEmpty(); - plan.OwnedCopiesAdded.Should().Be(2); - } - - [Fact] - public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceAlreadyExistsOnAnExistingBook() - { - // reproduces a real bug: re-running the same commit (or re-importing the same export) created a - // second owned version for the same order every time, because matching was title-only - var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); - var existing = Book("Some Book", new OwnedVersionModel { Reference = reference }); - - var plan = ComputeBookPlan([existing], [Item("Some Book", reference: reference)]); - - plan.ItemsToCreate.Should().BeEmpty(); - plan.ItemsToUpdate.Should().BeEmpty(); - existing.OwnedVersions.Should().ContainSingle(); - plan.OwnedCopiesAdded.Should().Be(0); - plan.OwnedCopiesSkipped.Should().Be(1); - } - - [Fact] - public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceMatchesAnExistingBook_EvenUnderADifferentTitleNow() - { - // reproduces a real bug: after a book's title was changed (e.g. by reference-data linking) following - // a first import, re-importing the same order under its original Amazon title no longer matched the - // existing book by title at all, and created a brand new duplicate record instead - var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); - var existing = Book("The Secret: Jack Reacher, Book 28", new OwnedVersionModel { Reference = reference }); - existing.Title = "No Plan B"; // renamed after the first import, e.g. by reference-data linking - - var plan = ComputeBookPlan([existing], [Item("The Secret: Jack Reacher, Book 28", reference: reference)]); - - plan.ItemsToCreate.Should().BeEmpty(); - plan.ItemsToUpdate.Should().BeEmpty(); - existing.OwnedVersions.Should().ContainSingle(); - plan.OwnedCopiesSkipped.Should().Be(1); - } - - [Fact] - public void ComputeCommitPlan_StillMergesASecondCopy_WhenTheOrderReferenceIsGenuinelyDifferent() - { - var existing = Book("Some Book", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin) }); - - var plan = ComputeBookPlan([existing], [Item("some book", reference: AmazonImportMergeService.FormatOrderReference("405-9999999-9999999", SomeAsin))]); - - plan.ItemsToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); - existing.OwnedVersions.Should().HaveCount(2); - plan.OwnedCopiesSkipped.Should().Be(0); - } - - [Fact] - public void ComputeCommitPlan_ImportsBothItems_WhenTwoDifferentItemsShareTheSameOrderId() - { - // reproduces a real bug: an Amazon order commonly contains several different line items, but the - // reference used to be built from the order id alone - so the second item in the same order was - // silently skipped as a "duplicate" of the first, even though it's a genuinely different product - const string sharedOrderId = "405-2222222-2222222"; - var firstItem = Item("First Book In Order", reference: AmazonImportMergeService.FormatOrderReference(sharedOrderId, "1111111111")); - var secondItem = Item("Second Book In Order", reference: AmazonImportMergeService.FormatOrderReference(sharedOrderId, "2222222222")); - - var plan = ComputeBookPlan([], [firstItem, secondItem]); - - plan.ItemsToCreate.Should().HaveCount(2); - plan.ItemsToCreate.Select(b => b.Title).Should().BeEquivalentTo(["First Book In Order", "Second Book In Order"]); - plan.OwnedCopiesAdded.Should().Be(2); - plan.OwnedCopiesSkipped.Should().Be(0); - } - - [Fact] - public void ComputeCommitPlan_WorksUnmodifiedForVideoGames_ViaPlatformsInsteadOfOwnedVersions() - { - var item = new AmazonVideoGameImportRequestItem - { - Title = "L.A. Noire", - AmazonTitle = "L.A. Noire", - Platform = new VideoGamePlatformModel { Platform = "PS3" } - }; - - var plan = AmazonImportMergeService.ComputeCommitPlan( - new List(), [item], - g => g.Title, g => g.Platforms.Select(p => p.Reference), - i => i.Title, i => i.Platform.Reference, - requestItem => new VideoGameModel { OwnerId = OwnerId, Title = requestItem.Title, Platforms = [requestItem.Platform] }, - (game, requestItem) => game.Platforms.Add(requestItem.Platform)); - - plan.ItemsToCreate.Should().ContainSingle(); - plan.ItemsToCreate[0].Platforms.Should().ContainSingle().Which.Platform.Should().Be("PS3"); - plan.OwnedCopiesAdded.Should().Be(1); - } - [Fact] public void FormatOrderReference_IncludesBothTheOrderIdAndTheAsin() { @@ -207,32 +17,16 @@ public void FormatOrderReference_IncludesBothTheOrderIdAndTheAsin() } [Fact] - public void FindImportedReferences_ReturnsOnlyNonNullReferences() + public void BuildAmazonProvenanceNotes_IncludesTheIsbnLine_WhenAnIsbnIsGiven() { - var existingBooks = new List - { - Book("Book A", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin) }), - Book("Book B", new OwnedVersionModel { Reference = "Bought at a flea market" }), - Book("Book C", new OwnedVersionModel { Reference = null }) - }; - - var result = AmazonImportMergeService.FindImportedReferences(existingBooks, b => b.OwnedVersions.Select(v => v.Reference)); - - result.Should().BeEquivalentTo([AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin), "Bought at a flea market"]); + AmazonImportMergeService.BuildAmazonProvenanceNotes("The Secret: Jack Reacher, Book 28", "0552177571") + .Should().Be("Title from Amazon: The Secret: Jack Reacher, Book 28\nISBN from Amazon: 0552177571"); } [Fact] - public void FindImportedReferences_WorksAcrossVideoGamePlatformsToo() + public void BuildAmazonProvenanceNotes_OmitsTheIsbnLine_WhenThereIsNoIsbn() { - var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); - var existingGames = new List - { - Game("Game A", new VideoGamePlatformModel { Platform = "PS5", Reference = reference }), - Game("Game B", new VideoGamePlatformModel { Platform = "PC", Reference = null }) - }; - - var result = AmazonImportMergeService.FindImportedReferences(existingGames, g => g.Platforms.Select(p => p.Reference)); - - result.Should().BeEquivalentTo([reference]); + AmazonImportMergeService.BuildAmazonProvenanceNotes("A Book With No Isbn", null) + .Should().Be("Title from Amazon: A Book With No Isbn"); } } diff --git a/test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs b/test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs new file mode 100644 index 00000000..a5645956 --- /dev/null +++ b/test/WebApi.UnitTests/Services/GenericVideoGameImportServiceTest.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using AwesomeAssertions; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class GenericVideoGameImportServiceTest +{ + private static Stream ToStream(string csv) => new MemoryStream(Encoding.UTF8.GetBytes(csv)); + + private const string Csv = """ + Transaction Date,Game Name,Product Name,Platform,Vendor,Transaction Id,Order Id,Final Price (€) + 2019-08-02,Grand Theft Auto V (PS4),Grand Theft Auto V : Édition Premium,PS4,PlayStation Store,148888333444,70557088055,14.99 + 2021-03-15,Returnal,Returnal,PS5,PlayStation Store,200000000001,80000000001,79.99 + """; + + [Fact] + public void BuildPreview_StripsTheTrailingPlatformSuffixFromTheTitle() + { + var result = GenericVideoGameImportService.BuildPreview(ToStream(Csv), new HashSet()); + + result[0].Title.Should().Be("Grand Theft Auto V"); + } + + [Fact] + public void BuildPreview_LeavesATitleWithNoMatchingPlatformSuffixUnchanged() + { + var result = GenericVideoGameImportService.BuildPreview(ToStream(Csv), new HashSet()); + + result[1].Title.Should().Be("Returnal"); + } + + [Fact] + public void BuildPreview_CarriesTheProductNamePlatformAndVendorThrough() + { + var result = GenericVideoGameImportService.BuildPreview(ToStream(Csv), new HashSet()); + + result[0].ProductName.Should().Be("Grand Theft Auto V : Édition Premium"); + result[0].Platform.Should().Be("PS4"); + result[0].Vendor.Should().Be("PlayStation Store"); + } + + [Fact] + public void BuildPreview_ParsesThePriceAndTransactionDate() + { + var result = GenericVideoGameImportService.BuildPreview(ToStream(Csv), new HashSet()); + + result[0].Price.Should().Be(14.99m); + result[0].TransactionDate.Should().Be(new System.DateOnly(2019, 8, 2)); + } + + [Fact] + public void BuildPreview_LeavesPriceNull_WhenTheColumnIsBlank() + { + const string csv = """ + Transaction Date,Game Name,Product Name,Platform,Vendor,Transaction Id,Order Id,Final Price (€) + 2019-08-02,Some Game,Some Game,PS4,PlayStation Store,148888333444,70557088055, + """; + + var result = GenericVideoGameImportService.BuildPreview(ToStream(csv), new HashSet()); + + result[0].Price.Should().BeNull(); + } + + [Fact] + public void BuildPreview_FlagsARowWhoseTransactionOrderAndProductAreAlreadyImported() + { + var alreadyImported = new HashSet + { + GenericVideoGameImportService.FormatReference("148888333444", "70557088055", "Grand Theft Auto V : Édition Premium", "Grand Theft Auto V (PS4)") + }; + + var result = GenericVideoGameImportService.BuildPreview(ToStream(Csv), alreadyImported); + + result[0].AlreadyImported.Should().BeTrue(); + result[1].AlreadyImported.Should().BeFalse(); + } + + [Fact] + public void FormatReference_IncludesTheOrderIdTransactionIdAndProductName() + { + GenericVideoGameImportService.FormatReference("148888333444", "70557088055", "Grand Theft Auto V : Édition Premium", "Grand Theft Auto V (PS4)") + .Should().Be("Order 70557088055 (transaction 148888333444) - Grand Theft Auto V : Édition Premium"); + } + + [Fact] + public void FormatReference_FallsBackToTheTitle_WhenProductNameIsBlank() + { + GenericVideoGameImportService.FormatReference("148888333444", "70557088055", null, "Grand Theft Auto V (PS4)") + .Should().Be("Order 70557088055 (transaction 148888333444) - Grand Theft Auto V (PS4)"); + } + + [Fact] + public void FormatReference_Disambiguates_WhenTwoLinesShareTheSameTransactionAndOrder() + { + // reproduces a real bug: a single PSN transaction can bundle several different DLC packs under the + // exact same Transaction Id/Order Id, distinguishable only by Product Name + var first = GenericVideoGameImportService.FormatReference("786966888755333", "399229990555", "FAR CRY 4 Vallée des Yétis", "FAR CRY 4"); + var second = GenericVideoGameImportService.FormatReference("786966888755333", "399229990555", "Pack Hurk Deluxe", "FAR CRY 4"); + + first.Should().NotBe(second); + } + + [Fact] + public void BuildProvenanceNotes_IncludesTheVendorAndSourceTitle() + { + GenericVideoGameImportService.BuildProvenanceNotes("PlayStation Store", "Grand Theft Auto V (PS4)") + .Should().Be("Title from PlayStation Store: Grand Theft Auto V (PS4)"); + } + + [Fact] + public void CleanTitle_IsCaseInsensitiveOnThePlatformSuffix() + { + GenericVideoGameImportService.CleanTitle("Returnal (ps5)", "PS5").Should().Be("Returnal"); + } +} diff --git a/test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs b/test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs new file mode 100644 index 00000000..bc8a94ca --- /dev/null +++ b/test/WebApi.UnitTests/Services/OwnedItemImportMergeServiceTest.cs @@ -0,0 +1,232 @@ +using System.Collections.Generic; +using System.Linq; +using AwesomeAssertions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +[Trait("Category", "UnitTests")] +public class OwnedItemImportMergeServiceTest +{ + private const string OwnerId = "owner-1"; + private const string SomeOrderId = "405-1111111-1111111"; + private const string SomeAsin = "0552177571"; + + private static BookModel Book(string title, params OwnedVersionModel[] ownedVersions) => + new() { Id = title, OwnerId = OwnerId, Title = title, Author = string.Empty, OwnedVersions = [.. ownedVersions] }; + + private static VideoGameModel Game(string title, params VideoGamePlatformModel[] platforms) => + new() { Id = title, OwnerId = OwnerId, Title = title, Platforms = [.. platforms] }; + + private static AmazonOwnedItemImportRequestItem Item(string title, string? reference = null, string? amazonTitle = null, string? isbn = null) => new() + { + Title = title, + AmazonTitle = amazonTitle ?? title, + Isbn = isbn, + OwnedVersion = new OwnedVersionModel { Reference = reference } + }; + + /// Wires up the generic engine for BookModel, the same way AmazonImportController does. + private static ImportCommitPlan ComputeBookPlan(IReadOnlyCollection existing, IReadOnlyList items) => + OwnedItemImportMergeService.ComputeCommitPlan( + existing, items, + b => b.Title, b => b.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new BookModel + { + OwnerId = OwnerId, + Title = item.Title, + Author = string.Empty, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, item.Isbn), + OwnedVersions = [item.OwnedVersion] + }, + (book, item) => book.OwnedVersions.Add(item.OwnedVersion)); + + [Fact] + public void ComputeCommitPlan_CreatesANewBook_WhenNoExistingBookMatchesTheTitle() + { + var plan = ComputeBookPlan([], [Item("The Secret")]); + + plan.ItemsToCreate.Should().ContainSingle(); + plan.ItemsToCreate[0].Title.Should().Be("The Secret"); + plan.ItemsToCreate[0].Author.Should().Be(string.Empty); + plan.ItemsToCreate[0].OwnedVersions.Should().ContainSingle(); + plan.ItemsToUpdate.Should().BeEmpty(); + plan.OwnedCopiesAdded.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_RecordsAmazonsOriginalTitleAndIsbnInNotes_ForANewlyCreatedBook() + { + var item = Item("The Secret", amazonTitle: "The Secret: Jack Reacher, Book 28", isbn: "0552177571"); + + var plan = ComputeBookPlan([], [item]); + + plan.ItemsToCreate[0].Notes.Should().Be("Title from Amazon: The Secret: Jack Reacher, Book 28\nISBN from Amazon: 0552177571"); + } + + [Fact] + public void ComputeCommitPlan_OmitsTheIsbnNoteLine_WhenThereIsNoIsbn() + { + var item = Item("A Book With No Isbn", amazonTitle: "A Book With No Isbn"); + + var plan = ComputeBookPlan([], [item]); + + plan.ItemsToCreate[0].Notes.Should().Be("Title from Amazon: A Book With No Isbn"); + } + + [Fact] + public void ComputeCommitPlan_DoesNotTouchNotes_WhenMergingIntoAnExistingBook() + { + var existing = Book("Some Book"); + existing.Notes = "My own pre-existing notes"; + + var plan = ComputeBookPlan([existing], [Item("some book", amazonTitle: "Some Book (Amazon listing)")]); + + plan.ItemsToCreate.Should().BeEmpty(); + existing.Notes.Should().Be("My own pre-existing notes"); + } + + [Fact] + public void ComputeCommitPlan_MergesIntoAnExistingBook_WhenTheNormalizedTitleMatches() + { + var existing = Book(" Some Book "); + + var plan = ComputeBookPlan([existing], [Item("some book")]); + + plan.ItemsToCreate.Should().BeEmpty(); + plan.ItemsToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); + existing.OwnedVersions.Should().ContainSingle(); + plan.OwnedCopiesAdded.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_MergesTwoSelectedRowsSharingATitle_IntoOneNewBook() + { + var plan = ComputeBookPlan([], [Item("Duplicate Title"), Item("duplicate title")]); + + plan.ItemsToCreate.Should().ContainSingle(); + plan.ItemsToCreate[0].OwnedVersions.Should().HaveCount(2); + plan.ItemsToUpdate.Should().BeEmpty(); + plan.OwnedCopiesAdded.Should().Be(2); + } + + [Fact] + public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceAlreadyExistsOnAnExistingBook() + { + // reproduces a real bug: re-running the same commit (or re-importing the same export) created a + // second owned version for the same order every time, because matching was title-only + var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); + var existing = Book("Some Book", new OwnedVersionModel { Reference = reference }); + + var plan = ComputeBookPlan([existing], [Item("Some Book", reference: reference)]); + + plan.ItemsToCreate.Should().BeEmpty(); + plan.ItemsToUpdate.Should().BeEmpty(); + existing.OwnedVersions.Should().ContainSingle(); + plan.OwnedCopiesAdded.Should().Be(0); + plan.OwnedCopiesSkipped.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_SkipsARow_WhenItsOrderReferenceMatchesAnExistingBook_EvenUnderADifferentTitleNow() + { + // reproduces a real bug: after a book's title was changed (e.g. by reference-data linking) following + // a first import, re-importing the same order under its original Amazon title no longer matched the + // existing book by title at all, and created a brand new duplicate record instead + var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); + var existing = Book("The Secret: Jack Reacher, Book 28", new OwnedVersionModel { Reference = reference }); + existing.Title = "No Plan B"; // renamed after the first import, e.g. by reference-data linking + + var plan = ComputeBookPlan([existing], [Item("The Secret: Jack Reacher, Book 28", reference: reference)]); + + plan.ItemsToCreate.Should().BeEmpty(); + plan.ItemsToUpdate.Should().BeEmpty(); + existing.OwnedVersions.Should().ContainSingle(); + plan.OwnedCopiesSkipped.Should().Be(1); + } + + [Fact] + public void ComputeCommitPlan_StillMergesASecondCopy_WhenTheOrderReferenceIsGenuinelyDifferent() + { + var existing = Book("Some Book", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin) }); + + var plan = ComputeBookPlan([existing], [Item("some book", reference: AmazonImportMergeService.FormatOrderReference("405-9999999-9999999", SomeAsin))]); + + plan.ItemsToUpdate.Should().ContainSingle().Which.Should().BeSameAs(existing); + existing.OwnedVersions.Should().HaveCount(2); + plan.OwnedCopiesSkipped.Should().Be(0); + } + + [Fact] + public void ComputeCommitPlan_ImportsBothItems_WhenTwoDifferentItemsShareTheSameOrderId() + { + // reproduces a real bug: an Amazon order commonly contains several different line items, but the + // reference used to be built from the order id alone - so the second item in the same order was + // silently skipped as a "duplicate" of the first, even though it's a genuinely different product + const string sharedOrderId = "405-2222222-2222222"; + var firstItem = Item("First Book In Order", reference: AmazonImportMergeService.FormatOrderReference(sharedOrderId, "1111111111")); + var secondItem = Item("Second Book In Order", reference: AmazonImportMergeService.FormatOrderReference(sharedOrderId, "2222222222")); + + var plan = ComputeBookPlan([], [firstItem, secondItem]); + + plan.ItemsToCreate.Should().HaveCount(2); + plan.ItemsToCreate.Select(b => b.Title).Should().BeEquivalentTo(["First Book In Order", "Second Book In Order"]); + plan.OwnedCopiesAdded.Should().Be(2); + plan.OwnedCopiesSkipped.Should().Be(0); + } + + [Fact] + public void ComputeCommitPlan_WorksUnmodifiedForVideoGames_ViaPlatformsInsteadOfOwnedVersions() + { + var item = new AmazonVideoGameImportRequestItem + { + Title = "L.A. Noire", + AmazonTitle = "L.A. Noire", + Platform = new VideoGamePlatformModel { Platform = "PS3" } + }; + + var plan = OwnedItemImportMergeService.ComputeCommitPlan( + new List(), [item], + g => g.Title, g => g.Platforms.Select(p => p.Reference), + i => i.Title, i => i.Platform.Reference, + requestItem => new VideoGameModel { OwnerId = OwnerId, Title = requestItem.Title, Platforms = [requestItem.Platform] }, + (game, requestItem) => game.Platforms.Add(requestItem.Platform)); + + plan.ItemsToCreate.Should().ContainSingle(); + plan.ItemsToCreate[0].Platforms.Should().ContainSingle().Which.Platform.Should().Be("PS3"); + plan.OwnedCopiesAdded.Should().Be(1); + } + + [Fact] + public void FindImportedReferences_ReturnsOnlyNonNullReferences() + { + var existingBooks = new List + { + Book("Book A", new OwnedVersionModel { Reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin) }), + Book("Book B", new OwnedVersionModel { Reference = "Bought at a flea market" }), + Book("Book C", new OwnedVersionModel { Reference = null }) + }; + + var result = OwnedItemImportMergeService.FindImportedReferences(existingBooks, b => b.OwnedVersions.Select(v => v.Reference)); + + result.Should().BeEquivalentTo([AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin), "Bought at a flea market"]); + } + + [Fact] + public void FindImportedReferences_WorksAcrossVideoGamePlatformsToo() + { + var reference = AmazonImportMergeService.FormatOrderReference(SomeOrderId, SomeAsin); + var existingGames = new List + { + Game("Game A", new VideoGamePlatformModel { Platform = "PS5", Reference = reference }), + Game("Game B", new VideoGamePlatformModel { Platform = "PC", Reference = null }) + }; + + var result = OwnedItemImportMergeService.FindImportedReferences(existingGames, g => g.Platforms.Select(p => p.Reference)); + + result.Should().BeEquivalentTo([reference]); + } +} From 954cbf7daf2e0bf0d5c94548dacb8c589a0b1441 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 22 Jul 2026 23:33:15 +0200 Subject: [PATCH 09/35] Add optional cover image URL to Car, House, and HealthProfile Reuses the plain-scalar shape already established for these three types (no reference-linking, so no override/precedence logic needed): shown as a thumbnail on the list page and editable on the detail page header. --- .../Inventory/Pages/CarDetail.razor | 12 +++++++++++ .../Components/Inventory/Pages/Cars.razor | 1 + .../Inventory/Pages/HealthProfileDetail.razor | 20 +++++++++++++++++-- .../Inventory/Pages/HealthProfiles.razor | 1 + .../Inventory/Pages/HouseDetail.razor | 12 +++++++++++ .../Components/Inventory/Pages/Houses.razor | 1 + src/Domain/Models/CarModel.cs | 5 +++++ src/Domain/Models/HealthProfileModel.cs | 5 +++++ src/Domain/Models/HouseModel.cs | 5 +++++ src/Infrastructure.MongoDb/Entities/Car.cs | 3 +++ .../Entities/HealthProfile.cs | 3 +++ src/Infrastructure.MongoDb/Entities/House.cs | 3 +++ src/WebApi.Contracts/Dto/CarDto.cs | 5 +++++ src/WebApi.Contracts/Dto/HealthProfileDto.cs | 5 +++++ src/WebApi.Contracts/Dto/HouseDto.cs | 5 +++++ .../Resources/CarResourceTest.cs | 1 + .../Resources/HealthProfileResourceTest.cs | 1 + .../Resources/HouseResourceTest.cs | 1 + 18 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index d5198b1f..fa4a06c5 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -24,6 +24,7 @@ else
+
@@ -46,6 +47,10 @@ else
+
+ + +
@@ -362,6 +367,13 @@ else await SaveCarAsync(); } + private async Task SaveImageUrlAsync(ChangeEventArgs e) + { + if (Car is null) return; + Car.ImageUrl = e.Value?.ToString(); + await SaveCarAsync(); + } + private void ShowAddModal() { _modalEntry = CarHistoryForm.NewEntry(Id); diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor b/src/BlazorApp/Components/Inventory/Pages/Cars.razor index 74737f4e..7d86325f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Cars.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor @@ -17,6 +17,7 @@ ShowForm="@_showForm" Form="@_form" ItemTitle="@(car => car.Name)" + ItemImageUrl="@(car => car.ImageUrl)" OnSearchKeyUp="@OnSearchKeyUp" OnClearSearch="@ClearSearch" OnGoToPage="@GoToPage" diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 106b4253..6fce3c63 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -24,13 +24,22 @@ else
+
- - +
+
+ + +
+
+ + +
+
@* No summary panels before the journal on purpose (owner feedback): @@ -258,6 +267,13 @@ else await SaveProfileAsync(); } + private async Task SaveImageUrlAsync(ChangeEventArgs e) + { + if (Profile is null) return; + Profile.ImageUrl = e.Value?.ToString(); + await SaveProfileAsync(); + } + private void ShowAddModal() { _modalEntry = HealthRecordForm.NewEntry(Id); diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor index cabc274a..bf6c65de 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor @@ -17,6 +17,7 @@ ShowForm="@_showForm" Form="@_form" ItemTitle="@(profile => profile.Name)" + ItemImageUrl="@(profile => profile.ImageUrl)" OnSearchKeyUp="@OnSearchKeyUp" OnClearSearch="@ClearSearch" OnGoToPage="@GoToPage" diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index 2f055de0..61c09d7f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -24,6 +24,7 @@ else
+
@@ -53,6 +54,10 @@ else
+
+ + +
@@ -324,6 +329,13 @@ else await SaveHouseAsync(); } + private async Task SaveImageUrlAsync(ChangeEventArgs e) + { + if (House is null) return; + House.ImageUrl = e.Value?.ToString(); + await SaveHouseAsync(); + } + private void ShowAddModal() { _modalEntry = HouseHistoryForm.NewEntry(Id); diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor index 4eb9a0ef..6d481d8b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Houses.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor @@ -17,6 +17,7 @@ ShowForm="@_showForm" Form="@_form" ItemTitle="@(house => house.Name)" + ItemImageUrl="@(house => house.ImageUrl)" OnSearchKeyUp="@OnSearchKeyUp" OnClearSearch="@ClearSearch" OnGoToPage="@GoToPage" diff --git a/src/Domain/Models/CarModel.cs b/src/Domain/Models/CarModel.cs index c4451401..014075a5 100644 --- a/src/Domain/Models/CarModel.cs +++ b/src/Domain/Models/CarModel.cs @@ -19,4 +19,9 @@ public class CarModel : IHasIdAndOwnerId public string? LicensePlate { get; set; } public required CarEnergyType EnergyType { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } } diff --git a/src/Domain/Models/HealthProfileModel.cs b/src/Domain/Models/HealthProfileModel.cs index 4299689d..ba0fdda1 100644 --- a/src/Domain/Models/HealthProfileModel.cs +++ b/src/Domain/Models/HealthProfileModel.cs @@ -16,4 +16,9 @@ public class HealthProfileModel : IHasIdAndOwnerId public required string Name { get; set; } public string? Notes { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } } diff --git a/src/Domain/Models/HouseModel.cs b/src/Domain/Models/HouseModel.cs index a35ab155..d322272b 100644 --- a/src/Domain/Models/HouseModel.cs +++ b/src/Domain/Models/HouseModel.cs @@ -22,4 +22,9 @@ public class HouseModel : IHasIdAndOwnerId public DateOnly? MovedOutAt { get; set; } public string? Notes { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/Car.cs b/src/Infrastructure.MongoDb/Entities/Car.cs index 246bc6de..8fb3603a 100644 --- a/src/Infrastructure.MongoDb/Entities/Car.cs +++ b/src/Infrastructure.MongoDb/Entities/Car.cs @@ -28,4 +28,7 @@ public class Car : IHasIdAndOwnerId [BsonElement("energy_type")] public required CarEnergyType EnergyType { get; set; } + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/HealthProfile.cs b/src/Infrastructure.MongoDb/Entities/HealthProfile.cs index ac47fcfc..06f545f5 100644 --- a/src/Infrastructure.MongoDb/Entities/HealthProfile.cs +++ b/src/Infrastructure.MongoDb/Entities/HealthProfile.cs @@ -16,4 +16,7 @@ public class HealthProfile : IHasIdAndOwnerId public required string Name { get; set; } public string? Notes { get; set; } + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/House.cs b/src/Infrastructure.MongoDb/Entities/House.cs index 9f0322f1..181e4a37 100644 --- a/src/Infrastructure.MongoDb/Entities/House.cs +++ b/src/Infrastructure.MongoDb/Entities/House.cs @@ -29,4 +29,7 @@ public class House : IHasIdAndOwnerId public DateTime? MovedOutAt { get; set; } public string? Notes { get; set; } + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } } diff --git a/src/WebApi.Contracts/Dto/CarDto.cs b/src/WebApi.Contracts/Dto/CarDto.cs index 1d9fb71a..996fe9cf 100644 --- a/src/WebApi.Contracts/Dto/CarDto.cs +++ b/src/WebApi.Contracts/Dto/CarDto.cs @@ -41,4 +41,9 @@ public class CarDto : IHasId /// Energy type (Combustion, Hybrid, Electric). ///
public CarEnergyType? EnergyType { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } } diff --git a/src/WebApi.Contracts/Dto/HealthProfileDto.cs b/src/WebApi.Contracts/Dto/HealthProfileDto.cs index 3a8e86b5..95e7a543 100644 --- a/src/WebApi.Contracts/Dto/HealthProfileDto.cs +++ b/src/WebApi.Contracts/Dto/HealthProfileDto.cs @@ -21,4 +21,9 @@ public class HealthProfileDto : IHasId /// Free-text notes (blood type, allergies, anything worth keeping at hand). ///
public string? Notes { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } } diff --git a/src/WebApi.Contracts/Dto/HouseDto.cs b/src/WebApi.Contracts/Dto/HouseDto.cs index 683583f0..d2e4ffe4 100644 --- a/src/WebApi.Contracts/Dto/HouseDto.cs +++ b/src/WebApi.Contracts/Dto/HouseDto.cs @@ -42,4 +42,9 @@ public class HouseDto : IHasId /// Free-text notes. /// public string? Notes { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } } diff --git a/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs b/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs index a60aebc6..32646e1c 100644 --- a/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/CarResourceTest.cs @@ -36,6 +36,7 @@ public async Task CarResourceFullCycle_IsOk() o.Year = f.Random.Int(1990, 2024); o.LicensePlate = f.Random.AlphaNumeric(8); o.EnergyType = CarEnergyType.Combustion; + o.ImageUrl = f.Internet.Url(); }) .Generate(); var created = await PostAsync($"/{ResourceEndpoint}", input); diff --git a/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs index 78e8272f..d30cbd74 100644 --- a/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HealthProfileResourceTest.cs @@ -33,6 +33,7 @@ public async Task HealthProfileResourceFullCycle_IsOk() try { created.Notes = "Allergic to penicillin"; + created.ImageUrl = "https://example.com/profile.jpg"; await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); diff --git a/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs b/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs index 230a99ca..f8a931f7 100644 --- a/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/HouseResourceTest.cs @@ -33,6 +33,7 @@ public async Task HouseResourceFullCycle_IsOk() o.PropertyType = f.PickRandom(); o.MovedInAt = System.DateOnly.FromDateTime(f.Date.Past()); o.MovedOutAt = System.DateOnly.FromDateTime(f.Date.Recent()); + o.ImageUrl = f.Internet.Url(); }) .Generate(); var created = await PostAsync($"/{ResourceEndpoint}", input); From eb74ee84b5c4b41f74d4059072d2cb1d1deebb29 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 22 Jul 2026 23:36:05 +0200 Subject: [PATCH 10/35] Persist the selected tab on Watch Next and Wishlist in the URL Mirrors the list pages' existing URL-state pattern (navigate, don't mutate) so browser back/forward and page reloads restore the tab that was showing, instead of always resetting to the first one. --- .../Components/WatchNext/WatchNextPage.razor | 14 +++++++++++++- .../Components/Wishlist/WishlistPage.razor | 12 +++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor index 78aad0d2..4b32c84e 100644 --- a/src/BlazorApp/Components/WatchNext/WatchNextPage.razor +++ b/src/BlazorApp/Components/WatchNext/WatchNextPage.razor @@ -99,6 +99,15 @@ else if (Data is not null) [Inject] private WatchNextApiClient WatchNextApi { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; + + // Persisted in the URL (?tab=) the same way list pages persist search/page/filters - see + // InventoryPageBase's ApplyQueryChanges/SetFilter for the pattern this mirrors. Unlike a list page's + // query params, changing this one never triggers a reload: Data isn't query-dependent here (WatchNextDto + // is one full, unpaged aggregate), so OnParametersSet only ever reparses the tab, it never calls LoadAsync. + [SupplyParameterFromQuery(Name = "tab")] + public string? TabQuery { get; set; } + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. // _loaded tracks whether a load attempt has finished at all - both default false so the forced // render Blazor triggers right after the synchronous prefix of OnInitializedAsync (before any @@ -117,6 +126,8 @@ else if (Data is not null) protected override Task OnInitializedAsync() => LoadAsync(); + protected override void OnParametersSet() => _tab = Enum.TryParse(TabQuery, out var tab) ? tab : Tab.TvShows; + private async Task LoadAsync() { await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); @@ -129,5 +140,6 @@ else if (Data is not null) Data = await WatchNextApi.GetAsync(); } - private void SelectTab(Tab tab) => _tab = tab; + private void SelectTab(Tab tab) => + Navigation.NavigateTo(Navigation.GetUriWithQueryParameters(new Dictionary { ["tab"] = tab.ToString() })); } diff --git a/src/BlazorApp/Components/Wishlist/WishlistPage.razor b/src/BlazorApp/Components/Wishlist/WishlistPage.razor index fd125c29..a03d8f7b 100644 --- a/src/BlazorApp/Components/Wishlist/WishlistPage.razor +++ b/src/BlazorApp/Components/Wishlist/WishlistPage.razor @@ -110,6 +110,13 @@ else if (Data is not null) [Inject] private IJSRuntime JsRuntime { get; set; } = null!; + // Persisted in the URL (?tab=) the same way list pages persist search/page/filters - see + // InventoryPageBase's ApplyQueryChanges/SetFilter for the pattern this mirrors. Unlike a list page's + // query params, changing this one never triggers a reload: Data isn't query-dependent here (WishlistDto + // is one full, unpaged aggregate), so OnParametersSet only ever reparses the tab, it never calls LoadAsync. + [SupplyParameterFromQuery(Name = "tab")] + public string? TabQuery { get; set; } + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. // _loaded tracks whether a load attempt has finished at all - both default false so the forced // render Blazor triggers right after the synchronous prefix of OnInitializedAsync (before any @@ -136,6 +143,8 @@ else if (Data is not null) protected override Task OnInitializedAsync() => LoadAsync(); + protected override void OnParametersSet() => _tab = Enum.TryParse(TabQuery, out var tab) ? tab : Tab.Movies; + private async Task LoadAsync() { await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); @@ -148,7 +157,8 @@ else if (Data is not null) Data = await WishlistApi.GetAsync(); } - private void SelectTab(Tab tab) => _tab = tab; + private void SelectTab(Tab tab) => + Navigation.NavigateTo(Navigation.GetUriWithQueryParameters(new Dictionary { ["tab"] = tab.ToString() })); private async Task ToggleSharePanelAsync() { From 33ff35ae32ef756863d903dd09eec04952963013 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 22 Jul 2026 23:37:47 +0200 Subject: [PATCH 11/35] Add ProductName to the shared OwnedVersionModel (Movie/TvShow/Book/Album) Mirrors VideoGamePlatformModel.ProductName, generalized onto the one shared owned-copy shape so it's per-copy for free: a season 6 Blu-ray, a season 1 digital copy, and an old season 1 DVD can each carry their own store listing text. Rendered through OwnedVersionFields' existing ExtraFields slot in OwnedVersionsEditor, shared by all four types. --- .../Inventory/Shared/OwnedVersionsEditor.razor | 16 +++++++++++++++- src/Domain/Models/OwnedVersionModel.cs | 6 ++++++ .../Entities/OwnedVersion.cs | 3 +++ src/WebApi.Contracts/Dto/OwnedVersionDto.cs | 6 ++++++ .../Resources/AlbumResourceTest.cs | 2 +- 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionsEditor.razor b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionsEditor.razor index 60df46ec..4be083ea 100644 --- a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionsEditor.razor +++ b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionsEditor.razor @@ -34,6 +34,13 @@ } + +
+ + +
+
@if (isDraft) { @@ -112,7 +119,14 @@ version.Price is null && version.AcquiredAt is null && string.IsNullOrWhiteSpace(version.Vendor) - && string.IsNullOrWhiteSpace(version.Reference); + && string.IsNullOrWhiteSpace(version.Reference) + && string.IsNullOrWhiteSpace(version.ProductName); + + private async Task SetProductNameAsync(OwnedVersionDto version, ChangeEventArgs e) + { + version.ProductName = e.Value?.ToString(); + await NotifyChangedAsync(version); + } /// Saves after a mutation on a saved copy; a draft's edits stay local until its Save button. private async Task NotifyChangedAsync(OwnedVersionDto version) diff --git a/src/Domain/Models/OwnedVersionModel.cs b/src/Domain/Models/OwnedVersionModel.cs index e2bdf3ef..a98bc604 100644 --- a/src/Domain/Models/OwnedVersionModel.cs +++ b/src/Domain/Models/OwnedVersionModel.cs @@ -31,4 +31,10 @@ public class OwnedVersionModel /// Unrelated to the reference-data ReferenceId concept. /// public string? Reference { get; set; } + + /// + /// The store's own specific product/edition text for this copy (e.g. a retailer's listing title), + /// distinct from the item's own Title. Optional free text. + /// + public string? ProductName { get; set; } } diff --git a/src/Infrastructure.MongoDb/Entities/OwnedVersion.cs b/src/Infrastructure.MongoDb/Entities/OwnedVersion.cs index 5ddcd06e..7d38b462 100644 --- a/src/Infrastructure.MongoDb/Entities/OwnedVersion.cs +++ b/src/Infrastructure.MongoDb/Entities/OwnedVersion.cs @@ -19,4 +19,7 @@ public class OwnedVersion public DateTime? AcquiredAt { get; set; } public string? Reference { get; set; } + + [BsonElement("product_name")] + public string? ProductName { get; set; } } diff --git a/src/WebApi.Contracts/Dto/OwnedVersionDto.cs b/src/WebApi.Contracts/Dto/OwnedVersionDto.cs index 48b46fd3..11c0b1a4 100644 --- a/src/WebApi.Contracts/Dto/OwnedVersionDto.cs +++ b/src/WebApi.Contracts/Dto/OwnedVersionDto.cs @@ -32,4 +32,10 @@ public class OwnedVersionDto : IOwnedCopyDto /// Free-text reference for this copy: edition name, order number, barcode... /// public string? Reference { get; set; } + + /// + /// The store's own specific product/edition text for this copy (e.g. a retailer's listing title), + /// distinct from the item's own Title. Optional free text. + /// + public string? ProductName { get; set; } } diff --git a/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs index bb71c928..6ff7371c 100644 --- a/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/AlbumResourceTest.cs @@ -102,7 +102,7 @@ public async Task AlbumResourceOwnedFilter_OnlyReturnsAlbumsWithOwnedVersions_Is Title = title, Artist = "Owned Filter Artist", // "owned" is derived from having at least one owned version, not a stored flag - OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, Price = 24.50m, Vendor = "Record store", Reference = "Vinyl reissue" }] + OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, Price = 24.50m, Vendor = "Record store", Reference = "Vinyl reissue", ProductName = "Deluxe vinyl edition" }] }); var notOwned = await PostAsync($"/{ResourceEndpoint}", new AlbumDto { Title = title, Artist = "Owned Filter Artist" }); From 655f29044fc02ff871ad539fc8ea149fe61b5902 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 22 Jul 2026 23:46:13 +0200 Subject: [PATCH 12/35] Add Collectible and Gear trackable item types Scaffolded by direct analogy to Album (CLAUDE.md's "Adding a new trackable item type" recipe), minus reference-linking since neither type has an external data provider. Both are member-only, minimal (title, brand, year, notes, optional cover image, favorite, owned versions) and share the OwnedVersionsEditor/OwnedVersionFields components used by every other owned-copy type. --- scripts/mongodb-create-index.js | 22 +++ .../Inventory/Clients/CollectibleApiClient.cs | 9 ++ .../Inventory/Clients/GearApiClient.cs | 9 ++ .../Inventory/Pages/CollectibleDetail.razor | 152 +++++++++++++++++ .../Inventory/Pages/Collectibles.razor | 64 ++++++++ .../Inventory/Pages/Collectibles.razor.cs | 30 ++++ .../Components/Inventory/Pages/Gear.razor | 64 ++++++++ .../Components/Inventory/Pages/Gear.razor.cs | 30 ++++ .../Inventory/Pages/GearDetail.razor | 153 ++++++++++++++++++ src/BlazorApp/Components/Layout/NavMenu.razor | 10 ++ src/BlazorApp/Components/Pages/Home.razor | 4 +- src/Domain/Models/CollectibleModel.cs | 34 ++++ src/Domain/Models/GearModel.cs | 34 ++++ .../Repositories/ICollectibleRepository.cs | 7 + src/Domain/Repositories/IGearRepository.cs | 7 + .../Entities/Collectible.cs | 33 ++++ src/Infrastructure.MongoDb/Entities/Gear.cs | 33 ++++ .../Mappers/CollectibleStorageMapper.cs | 20 +++ .../Mappers/GearStorageMapper.cs | 20 +++ .../Repositories/CollectibleRepository.cs | 30 ++++ .../Repositories/GearRepository.cs | 30 ++++ src/WebApi.Contracts/Dto/CollectibleDto.cs | 55 +++++++ .../Dto/CollectionStatsDto.cs | 6 + src/WebApi.Contracts/Dto/GearDto.cs | 55 +++++++ .../Controllers/CollectibleController.cs | 13 ++ src/WebApi/Controllers/GearController.cs | 13 ++ src/WebApi/Controllers/StatsController.cs | 8 +- ...frastructureServiceCollectionExtensions.cs | 4 + src/WebApi/Mappers/CollectibleDtoMapper.cs | 16 ++ src/WebApi/Mappers/GearDtoMapper.cs | 16 ++ src/WebApi/Program.cs | 2 + .../Resources/CollectibleResourceTest.cs | 114 +++++++++++++ .../Resources/GearResourceTest.cs | 114 +++++++++++++ .../Controllers/FreeTierTest.cs | 2 + 34 files changed, 1210 insertions(+), 3 deletions(-) create mode 100644 src/BlazorApp/Components/Inventory/Clients/CollectibleApiClient.cs create mode 100644 src/BlazorApp/Components/Inventory/Clients/GearApiClient.cs create mode 100644 src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor create mode 100644 src/BlazorApp/Components/Inventory/Pages/Collectibles.razor create mode 100644 src/BlazorApp/Components/Inventory/Pages/Collectibles.razor.cs create mode 100644 src/BlazorApp/Components/Inventory/Pages/Gear.razor create mode 100644 src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs create mode 100644 src/BlazorApp/Components/Inventory/Pages/GearDetail.razor create mode 100644 src/Domain/Models/CollectibleModel.cs create mode 100644 src/Domain/Models/GearModel.cs create mode 100644 src/Domain/Repositories/ICollectibleRepository.cs create mode 100644 src/Domain/Repositories/IGearRepository.cs create mode 100644 src/Infrastructure.MongoDb/Entities/Collectible.cs create mode 100644 src/Infrastructure.MongoDb/Entities/Gear.cs create mode 100644 src/Infrastructure.MongoDb/Mappers/CollectibleStorageMapper.cs create mode 100644 src/Infrastructure.MongoDb/Mappers/GearStorageMapper.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/CollectibleRepository.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/GearRepository.cs create mode 100644 src/WebApi.Contracts/Dto/CollectibleDto.cs create mode 100644 src/WebApi.Contracts/Dto/GearDto.cs create mode 100644 src/WebApi/Controllers/CollectibleController.cs create mode 100644 src/WebApi/Controllers/GearController.cs create mode 100644 src/WebApi/Mappers/CollectibleDtoMapper.cs create mode 100644 src/WebApi/Mappers/GearDtoMapper.cs create mode 100644 test/WebApi.IntegrationTests/Resources/CollectibleResourceTest.cs create mode 100644 test/WebApi.IntegrationTests/Resources/GearResourceTest.cs diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index f56043ea..bbc66b9b 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -26,6 +26,8 @@ ensureIndex(db.house, { owner_id: 1 }, { name: "house_owner" }); ensureIndex(db.house_history, { owner_id: 1 }, { name: "house_history_owner" }); ensureIndex(db.health_profile, { owner_id: 1 }, { name: "health_profile_owner" }); ensureIndex(db.health_record, { owner_id: 1 }, { name: "health_record_owner" }); +ensureIndex(db.collectible, { owner_id: 1 }, { name: "collectible_owner" }); +ensureIndex(db.gear, { owner_id: 1 }, { name: "gear_owner" }); ensureIndex(db.movie, { owner_id: 1 }, { name: "movie_owner" }); ensureIndex(db.tvshow, { owner_id: 1 }, { name: "tvshow_owner" }); ensureIndex(db.videogame, { owner_id: 1 }, { name: "videogame_owner" }); @@ -91,6 +93,16 @@ ensureIndex( { owner_id: 1, is_favorite: 1 }, { name: "book_favorite", partialFilterExpression: { is_favorite: true } } ); +ensureIndex( + db.collectible, + { owner_id: 1, is_favorite: 1 }, + { name: "collectible_favorite", partialFilterExpression: { is_favorite: true } } +); +ensureIndex( + db.gear, + { owner_id: 1, is_favorite: 1 }, + { name: "gear_favorite", partialFilterExpression: { is_favorite: true } } +); // movie / tvshow / book / videogame: same sparse-flag partial-index rationale as the favorite/want-to-watch // indexes above, for the is_wishlisted flag. VideoGame has no favorite/want-to-watch flags, so this is its @@ -147,6 +159,16 @@ ensureIndex( { owner_id: 1 }, { name: "videogame_owned", partialFilterExpression: { "platforms.0": { $exists: true } } } ); +ensureIndex( + db.collectible, + { owner_id: 1 }, + { name: "collectible_owned", partialFilterExpression: { "owned_versions.0": { $exists: true } } } +); +ensureIndex( + db.gear, + { owner_id: 1 }, + { name: "gear_owned", partialFilterExpression: { "owned_versions.0": { $exists: true } } } +); // background_job: transient job-progress documents (TV Time import, reference-data "sync now") polled by // job id - MongoDB-backed (instead of in-memory) so any WebApi replica can answer a poll. TTL cleanup diff --git a/src/BlazorApp/Components/Inventory/Clients/CollectibleApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/CollectibleApiClient.cs new file mode 100644 index 00000000..30a1d6fc --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Clients/CollectibleApiClient.cs @@ -0,0 +1,9 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Inventory.Clients; + +public sealed class CollectibleApiClient(HttpClient http) + : InventoryApiClientBase(http) +{ + protected override string ApiResourceName => "/api/collectibles"; +} diff --git a/src/BlazorApp/Components/Inventory/Clients/GearApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/GearApiClient.cs new file mode 100644 index 00000000..235fc3e3 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Clients/GearApiClient.cs @@ -0,0 +1,9 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Inventory.Clients; + +public sealed class GearApiClient(HttpClient http) + : InventoryApiClientBase(http) +{ + protected override string ApiResourceName => "/api/gear"; +} diff --git a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor new file mode 100644 index 00000000..8b54b0cd --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor @@ -0,0 +1,152 @@ +@page "/collectibles/{Id}" +@attribute [Authorize] + +@if (!_loaded) +{ + @if (_loading) + { +
+
+ Loading… +
+ } +} +else if (Collectible is null) +{ +
+
+

Collectible not found.

+
+} +else +{ + + +
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private CollectibleApiClient CollectibleApi { get; set; } = null!; + + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; + + // Public property (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as AlbumDetail/CarDetail. + [PersistentState] + public CollectibleDto? Collectible { get; set; } + + protected override async Task OnParametersSetAsync() + { + // Collectible already holds this route's item when PersistentState restored the prerendered data + // the id check keeps an in-circuit navigation to a different collectible reloading as before + if (Collectible?.Id == Id) + { + _loading = false; + _loaded = true; + return; + } + await LoadAsync(); + } + + private async Task LoadAsync() + { + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { + Collectible = await CollectibleApi.GetOneAsync(Id); + } + + private async Task SaveCollectibleAsync() + { + if (Collectible is null) return; + await CollectibleApi.UpdateAsync(Collectible); + } + + private async Task SaveTitleAsync(ChangeEventArgs e) + { + if (Collectible is null) return; + var title = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(title)) return; + Collectible.Title = title; + await SaveCollectibleAsync(); + } + + private async Task SaveBrandAsync(ChangeEventArgs e) + { + if (Collectible is null) return; + Collectible.Brand = e.Value?.ToString(); + await SaveCollectibleAsync(); + } + + private async Task SaveYearAsync(ChangeEventArgs e) + { + if (Collectible is null) return; + Collectible.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await SaveCollectibleAsync(); + } + + private async Task SaveNotesAsync(ChangeEventArgs e) + { + if (Collectible is null) return; + Collectible.Notes = e.Value?.ToString(); + await SaveCollectibleAsync(); + } + + private async Task SaveImageUrlAsync(ChangeEventArgs e) + { + if (Collectible is null) return; + Collectible.ImageUrl = e.Value?.ToString(); + await SaveCollectibleAsync(); + } + + private async Task ToggleFavoriteAsync() + { + if (Collectible is null) return; + Collectible.IsFavorite = !Collectible.IsFavorite; + await SaveCollectibleAsync(); + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor b/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor new file mode 100644 index 00000000..742b88f8 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor @@ -0,0 +1,64 @@ +@page "/collectibles" +@inherits InventoryPageBase +@attribute [Authorize] + + + + + + + + @if (!string.IsNullOrEmpty(collectible.Brand)) + { + @collectible.Brand + } + @if (collectible.Year > 0) + { + @collectible.Year + } + @if (collectible.IsFavorite) + { + Favorite + } + @if (collectible.OwnedVersions.Count > 0) + { + Owned + } + + +
+ +
+
+ +
+
+ +
+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor.cs new file mode 100644 index 00000000..3d0c11fa --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor.cs @@ -0,0 +1,30 @@ +using Keeptrack.WebApi.Contracts.Dto; +using Microsoft.AspNetCore.Components; + +namespace Keeptrack.BlazorApp.Components.Inventory.Pages; + +public partial class Collectibles : InventoryPageBase +{ + [Inject] private CollectibleApiClient CollectibleApi { get; set; } = null!; + + protected override InventoryApiClientBase Api => CollectibleApi; + + protected override string ListRoute => "/collectibles"; + + [SupplyParameterFromQuery(Name = "favorite")] + public bool FavoriteFilter { get; set; } + + [SupplyParameterFromQuery(Name = "owned")] + public bool OwnedFilter { get; set; } + + protected override IReadOnlyDictionary? ExtraQuery + { + get + { + var query = new Dictionary(); + if (FavoriteFilter) query["IsFavorite"] = "true"; + if (OwnedFilter) query["IsOwned"] = "true"; + return query.Count > 0 ? query : null; + } + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/Gear.razor b/src/BlazorApp/Components/Inventory/Pages/Gear.razor new file mode 100644 index 00000000..d7f270d1 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Gear.razor @@ -0,0 +1,64 @@ +@page "/gear" +@inherits InventoryPageBase +@attribute [Authorize] + + + + + + + + @if (!string.IsNullOrEmpty(gear.Brand)) + { + @gear.Brand + } + @if (gear.Year > 0) + { + @gear.Year + } + @if (gear.IsFavorite) + { + Favorite + } + @if (gear.OwnedVersions.Count > 0) + { + Owned + } + + +
+ +
+
+ +
+
+ +
+
+
diff --git a/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs new file mode 100644 index 00000000..1fd1158b --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs @@ -0,0 +1,30 @@ +using Keeptrack.WebApi.Contracts.Dto; +using Microsoft.AspNetCore.Components; + +namespace Keeptrack.BlazorApp.Components.Inventory.Pages; + +public partial class Gear : InventoryPageBase +{ + [Inject] private GearApiClient GearApi { get; set; } = null!; + + protected override InventoryApiClientBase Api => GearApi; + + protected override string ListRoute => "/gear"; + + [SupplyParameterFromQuery(Name = "favorite")] + public bool FavoriteFilter { get; set; } + + [SupplyParameterFromQuery(Name = "owned")] + public bool OwnedFilter { get; set; } + + protected override IReadOnlyDictionary? ExtraQuery + { + get + { + var query = new Dictionary(); + if (FavoriteFilter) query["IsFavorite"] = "true"; + if (OwnedFilter) query["IsOwned"] = "true"; + return query.Count > 0 ? query : null; + } + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor new file mode 100644 index 00000000..dc0e6444 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor @@ -0,0 +1,153 @@ +@page "/gear/{Id}" +@attribute [Authorize] + +@if (!_loaded) +{ + @if (_loading) + { +
+
+ Loading… +
+ } +} +else if (Item is null) +{ +
+
+

Gear not found.

+
+} +else +{ + + +
+
+ + +
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +} + +@code { + [Parameter] public required string Id { get; set; } + + [Inject] private GearApiClient GearApi { get; set; } = null!; + + // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted + // prerender state - both default false so the forced render Blazor triggers right after the + // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead + // of a spinner flash on every navigation to this page. + private bool _loading; + private bool _loaded; + + // Public property (a framework requirement for [PersistentState]): the data loaded during the + // prerender pass is carried over to the interactive circuit, so the first interactive render reuses + // it instead of resetting to the spinner and re-fetching - same pattern as AlbumDetail/CarDetail. + // Named "Item" rather than "Gear" - a property can't share its containing partial class's own name. + [PersistentState] + public GearDto? Item { get; set; } + + protected override async Task OnParametersSetAsync() + { + // Item already holds this route's item when PersistentState restored the prerendered data + // the id check keeps an in-circuit navigation to a different item reloading as before + if (Item?.Id == Id) + { + _loading = false; + _loaded = true; + return; + } + await LoadAsync(); + } + + private async Task LoadAsync() + { + await LoadingIndicator.RunAsync(FetchAsync(), v => _loading = v, StateHasChanged); + _loading = false; + _loaded = true; + } + + private async Task FetchAsync() + { + Item = await GearApi.GetOneAsync(Id); + } + + private async Task SaveItemAsync() + { + if (Item is null) return; + await GearApi.UpdateAsync(Item); + } + + private async Task SaveTitleAsync(ChangeEventArgs e) + { + if (Item is null) return; + var title = e.Value?.ToString(); + if (string.IsNullOrWhiteSpace(title)) return; + Item.Title = title; + await SaveItemAsync(); + } + + private async Task SaveBrandAsync(ChangeEventArgs e) + { + if (Item is null) return; + Item.Brand = e.Value?.ToString(); + await SaveItemAsync(); + } + + private async Task SaveYearAsync(ChangeEventArgs e) + { + if (Item is null) return; + Item.Year = int.TryParse(e.Value?.ToString(), out var year) ? year : null; + await SaveItemAsync(); + } + + private async Task SaveNotesAsync(ChangeEventArgs e) + { + if (Item is null) return; + Item.Notes = e.Value?.ToString(); + await SaveItemAsync(); + } + + private async Task SaveImageUrlAsync(ChangeEventArgs e) + { + if (Item is null) return; + Item.ImageUrl = e.Value?.ToString(); + await SaveItemAsync(); + } + + private async Task ToggleFavoriteAsync() + { + if (Item is null) return; + Item.IsFavorite = !Item.IsFavorite; + await SaveItemAsync(); + } +} diff --git a/src/BlazorApp/Components/Layout/NavMenu.razor b/src/BlazorApp/Components/Layout/NavMenu.razor index 84706af8..0bfe4727 100644 --- a/src/BlazorApp/Components/Layout/NavMenu.razor +++ b/src/BlazorApp/Components/Layout/NavMenu.razor @@ -80,6 +80,16 @@ Health + + } else @@ -94,7 +96,7 @@ private long TotalItems => Stats is null ? 0 - : Stats.Movies + Stats.TvShows + Stats.Books + Stats.Albums + Stats.VideoGames + Stats.Playlists + Stats.Cars + Stats.Houses; + : Stats.Movies + Stats.TvShows + Stats.Books + Stats.Albums + Stats.VideoGames + Stats.Playlists + Stats.Cars + Stats.Houses + Stats.Collectibles + Stats.Gear; protected override async Task OnInitializedAsync() { diff --git a/src/Domain/Models/CollectibleModel.cs b/src/Domain/Models/CollectibleModel.cs new file mode 100644 index 00000000..0cbd1ba0 --- /dev/null +++ b/src/Domain/Models/CollectibleModel.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +public class CollectibleModel : IHasIdAndOwnerId +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + public required string Title { get; set; } + + public string? Brand { get; set; } + + public int? Year { get; set; } + + public string? Notes { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } + + public bool IsFavorite { get; set; } + + public List OwnedVersions { get; set; } = []; + + /// + /// Filter-only: matches if is non-empty. Never persisted - see + /// . + /// + public bool IsOwned { get; set; } +} diff --git a/src/Domain/Models/GearModel.cs b/src/Domain/Models/GearModel.cs new file mode 100644 index 00000000..f8d9c51e --- /dev/null +++ b/src/Domain/Models/GearModel.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +public class GearModel : IHasIdAndOwnerId +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + public required string Title { get; set; } + + public string? Brand { get; set; } + + public int? Year { get; set; } + + public string? Notes { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } + + public bool IsFavorite { get; set; } + + public List OwnedVersions { get; set; } = []; + + /// + /// Filter-only: matches if is non-empty. Never persisted - see + /// . + /// + public bool IsOwned { get; set; } +} diff --git a/src/Domain/Repositories/ICollectibleRepository.cs b/src/Domain/Repositories/ICollectibleRepository.cs new file mode 100644 index 00000000..a9e243eb --- /dev/null +++ b/src/Domain/Repositories/ICollectibleRepository.cs @@ -0,0 +1,7 @@ +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +public interface ICollectibleRepository : IDataRepository +{ +} diff --git a/src/Domain/Repositories/IGearRepository.cs b/src/Domain/Repositories/IGearRepository.cs new file mode 100644 index 00000000..30190341 --- /dev/null +++ b/src/Domain/Repositories/IGearRepository.cs @@ -0,0 +1,7 @@ +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +public interface IGearRepository : IDataRepository +{ +} diff --git a/src/Infrastructure.MongoDb/Entities/Collectible.cs b/src/Infrastructure.MongoDb/Entities/Collectible.cs new file mode 100644 index 00000000..71d97c0f --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/Collectible.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class Collectible : IHasIdAndOwnerId +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + public required string Title { get; set; } + + public string? Brand { get; set; } + + public int? Year { get; set; } + + public string? Notes { get; set; } + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } + + [BsonElement("is_favorite")] + public bool IsFavorite { get; set; } + + [BsonElement("owned_versions")] + public List OwnedVersions { get; set; } = []; +} diff --git a/src/Infrastructure.MongoDb/Entities/Gear.cs b/src/Infrastructure.MongoDb/Entities/Gear.cs new file mode 100644 index 00000000..9cc4f838 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/Gear.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class Gear : IHasIdAndOwnerId +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + public required string Title { get; set; } + + public string? Brand { get; set; } + + public int? Year { get; set; } + + public string? Notes { get; set; } + + [BsonElement("image_url")] + public string? ImageUrl { get; set; } + + [BsonElement("is_favorite")] + public bool IsFavorite { get; set; } + + [BsonElement("owned_versions")] + public List OwnedVersions { get; set; } = []; +} diff --git a/src/Infrastructure.MongoDb/Mappers/CollectibleStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/CollectibleStorageMapper.cs new file mode 100644 index 00000000..58a19614 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/CollectibleStorageMapper.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] +public partial class CollectibleStorageMapper : IStorageMapper +{ + // IsOwned is filter-only (derived from OwnedVersions) - see MovieStorageMapper. + [MapperIgnoreSource(nameof(CollectibleModel.IsOwned))] + public partial Collectible ToEntity(CollectibleModel model); + + [MapperIgnoreTarget(nameof(CollectibleModel.IsOwned))] + public partial CollectibleModel ToModel(Collectible entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Mappers/GearStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/GearStorageMapper.cs new file mode 100644 index 00000000..a4587c42 --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/GearStorageMapper.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonStorageMappings))] +public partial class GearStorageMapper : IStorageMapper +{ + // IsOwned is filter-only (derived from OwnedVersions) - see MovieStorageMapper. + [MapperIgnoreSource(nameof(GearModel.IsOwned))] + public partial Gear ToEntity(GearModel model); + + [MapperIgnoreTarget(nameof(GearModel.IsOwned))] + public partial GearModel ToModel(Gear entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Repositories/CollectibleRepository.cs b/src/Infrastructure.MongoDb/Repositories/CollectibleRepository.cs new file mode 100644 index 00000000..5dadafba --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/CollectibleRepository.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq.Expressions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class CollectibleRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), ICollectibleRepository +{ + protected override string CollectionName => "collectible"; + + protected override Expression> SortTitleField => x => x.Title; + + protected override FilterDefinition GetFilter(string ownerId, string? search, CollectibleModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, StringComparison.CurrentCultureIgnoreCase) + || (f.Brand != null && f.Brand.Contains(search, StringComparison.CurrentCultureIgnoreCase))); + if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); + // "owned" means at least one owned version - see MovieRepository.GetFilter + if (input.IsOwned) filter &= builder.SizeGt(f => f.OwnedVersions, 0); + return filter; + } +} diff --git a/src/Infrastructure.MongoDb/Repositories/GearRepository.cs b/src/Infrastructure.MongoDb/Repositories/GearRepository.cs new file mode 100644 index 00000000..9e5020d7 --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/GearRepository.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq.Expressions; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class GearRepository(IMongoDatabase mongoDatabase, ILogger logger, IStorageMapper mapper) + : MongoDbRepositoryBase(mongoDatabase, logger, mapper), IGearRepository +{ + protected override string CollectionName => "gear"; + + protected override Expression> SortTitleField => x => x.Title; + + protected override FilterDefinition GetFilter(string ownerId, string? search, GearModel input) + { + var builder = Builders.Filter; + var filter = builder.Eq(f => f.OwnerId, ownerId); + if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, StringComparison.CurrentCultureIgnoreCase) + || (f.Brand != null && f.Brand.Contains(search, StringComparison.CurrentCultureIgnoreCase))); + if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); + // "owned" means at least one owned version - see MovieRepository.GetFilter + if (input.IsOwned) filter &= builder.SizeGt(f => f.OwnedVersions, 0); + return filter; + } +} diff --git a/src/WebApi.Contracts/Dto/CollectibleDto.cs b/src/WebApi.Contracts/Dto/CollectibleDto.cs new file mode 100644 index 00000000..93ec07ca --- /dev/null +++ b/src/WebApi.Contracts/Dto/CollectibleDto.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// A collectible item the user owns (e.g. a Lego set, a figurine). +/// +public class CollectibleDto : IHasId +{ + /// + /// Unique identifier. + /// + public string? Id { get; set; } + + /// + /// Collectible name. + /// + /// Millennium Falcon + public string? Title { get; set; } + + /// + /// Brand or manufacturer. + /// + /// Lego + public string? Brand { get; set; } + + /// + /// Year. + /// + public int? Year { get; set; } + + /// + /// Free-text notes (where it's stored, where it's displayed, or any other specifics). + /// + public string? Notes { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } + + public bool IsFavorite { get; set; } + + /// + /// Every owned copy of this item - it counts as owned when this list is non-empty. + /// + public List OwnedVersions { get; set; } = []; + + /// + /// Filter-only query parameter: matches items with at least one owned version. Never populated on a + /// returned item - see for the convention. + /// + public bool IsOwned { get; set; } +} diff --git a/src/WebApi.Contracts/Dto/CollectionStatsDto.cs b/src/WebApi.Contracts/Dto/CollectionStatsDto.cs index d53e2ab4..3220c33c 100644 --- a/src/WebApi.Contracts/Dto/CollectionStatsDto.cs +++ b/src/WebApi.Contracts/Dto/CollectionStatsDto.cs @@ -31,4 +31,10 @@ public class CollectionStatsDto /// Number of houses. public long Houses { get; set; } + + /// Number of collectibles. + public long Collectibles { get; set; } + + /// Number of gear items. + public long Gear { get; set; } } diff --git a/src/WebApi.Contracts/Dto/GearDto.cs b/src/WebApi.Contracts/Dto/GearDto.cs new file mode 100644 index 00000000..688c81a7 --- /dev/null +++ b/src/WebApi.Contracts/Dto/GearDto.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Keeptrack.Common.System; + +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// A piece of gear/equipment the user owns (e.g. a TV, a bike, a keyboard). +/// +public class GearDto : IHasId +{ + /// + /// Unique identifier. + /// + public string? Id { get; set; } + + /// + /// Gear name. + /// + /// Standing desk + public string? Title { get; set; } + + /// + /// Brand or manufacturer. + /// + /// Sony + public string? Brand { get; set; } + + /// + /// Year. + /// + public int? Year { get; set; } + + /// + /// Free-text notes (where it's stored, where it's used, or any other specifics). + /// + public string? Notes { get; set; } + + /// + /// Tenant-owned cover image URL, shown on the list thumbnail and detail page header. Optional. + /// + public string? ImageUrl { get; set; } + + public bool IsFavorite { get; set; } + + /// + /// Every owned copy of this item - it counts as owned when this list is non-empty. + /// + public List OwnedVersions { get; set; } = []; + + /// + /// Filter-only query parameter: matches items with at least one owned version. Never populated on a + /// returned item - see for the convention. + /// + public bool IsOwned { get; set; } +} diff --git a/src/WebApi/Controllers/CollectibleController.cs b/src/WebApi/Controllers/CollectibleController.cs new file mode 100644 index 00000000..cba1b80a --- /dev/null +++ b/src/WebApi/Controllers/CollectibleController.cs @@ -0,0 +1,13 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize(Policy = "MemberOnly")] +[Route("api/collectibles")] +public class CollectibleController(IDtoMapper mapper, ICollectibleRepository dataRepository) + : DataCrudControllerBase(mapper, dataRepository); diff --git a/src/WebApi/Controllers/GearController.cs b/src/WebApi/Controllers/GearController.cs new file mode 100644 index 00000000..27287e6a --- /dev/null +++ b/src/WebApi/Controllers/GearController.cs @@ -0,0 +1,13 @@ +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +[ApiController] +[Authorize(Policy = "MemberOnly")] +[Route("api/gear")] +public class GearController(IDtoMapper mapper, IGearRepository dataRepository) + : DataCrudControllerBase(mapper, dataRepository); diff --git a/src/WebApi/Controllers/StatsController.cs b/src/WebApi/Controllers/StatsController.cs index 544f96c3..6eda553a 100644 --- a/src/WebApi/Controllers/StatsController.cs +++ b/src/WebApi/Controllers/StatsController.cs @@ -22,7 +22,9 @@ public class StatsController( IPlaylistRepository playlistRepository, IVideoGameRepository videoGameRepository, ICarRepository carRepository, - IHouseRepository houseRepository) : ControllerBase + IHouseRepository houseRepository, + ICollectibleRepository collectibleRepository, + IGearRepository gearRepository) : ControllerBase { /// /// How many items the caller has in each collection. @@ -43,7 +45,9 @@ public async Task> Get() Playlists = await playlistRepository.CountAsync(ownerId), VideoGames = await videoGameRepository.CountAsync(ownerId), Cars = await carRepository.CountAsync(ownerId), - Houses = await houseRepository.CountAsync(ownerId) + Houses = await houseRepository.CountAsync(ownerId), + Collectibles = await collectibleRepository.CountAsync(ownerId), + Gear = await gearRepository.CountAsync(ownerId) }); } } diff --git a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 133c8efe..c870a4c9 100644 --- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -41,6 +41,8 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services, services.AddSingleton, HouseHistoryStorageMapper>(); services.AddSingleton, HealthProfileStorageMapper>(); services.AddSingleton, HealthRecordStorageMapper>(); + services.AddSingleton, CollectibleStorageMapper>(); + services.AddSingleton, GearStorageMapper>(); services.AddSingleton(); services.AddSingleton(); @@ -64,6 +66,8 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services, services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/WebApi/Mappers/CollectibleDtoMapper.cs b/src/WebApi/Mappers/CollectibleDtoMapper.cs new file mode 100644 index 00000000..d9b38d6f --- /dev/null +++ b/src/WebApi/Mappers/CollectibleDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class CollectibleDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(CollectibleModel.OwnerId), "")] + public partial CollectibleModel ToModel(CollectibleDto dto); + + [MapperIgnoreSource(nameof(CollectibleModel.OwnerId))] + public partial CollectibleDto ToDto(CollectibleModel model); +} diff --git a/src/WebApi/Mappers/GearDtoMapper.cs b/src/WebApi/Mappers/GearDtoMapper.cs new file mode 100644 index 00000000..1c8730c7 --- /dev/null +++ b/src/WebApi/Mappers/GearDtoMapper.cs @@ -0,0 +1,16 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +[Mapper] +[UseStaticMapper(typeof(CommonDtoMappings))] +public partial class GearDtoMapper : IDtoMapper +{ + // see BookDtoMapper.ToModel for why MapValue (not MapperIgnoreTarget) is required here + [MapValue(nameof(GearModel.OwnerId), "")] + public partial GearModel ToModel(GearDto dto); + + [MapperIgnoreSource(nameof(GearModel.OwnerId))] + public partial GearDto ToDto(GearModel model); +} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index e65d903e..5a186896 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -19,6 +19,8 @@ builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HouseHistoryDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HealthProfileDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.HealthRecordDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.CollectibleDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.GearDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.EpisodeDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.MovieDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.AlbumDtoMapper>(); diff --git a/test/WebApi.IntegrationTests/Resources/CollectibleResourceTest.cs b/test/WebApi.IntegrationTests/Resources/CollectibleResourceTest.cs new file mode 100644 index 00000000..9c3d6ae1 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/CollectibleResourceTest.cs @@ -0,0 +1,114 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Bogus; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for Collectible, same shape as +/// minus reference-linking (Collectible has no external reference-data provider). +/// +public class CollectibleResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/collectibles"; + + [Fact] + public async Task CollectibleResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var input = new Faker() + .Rules((f, o) => + { + o.Title = f.Random.AlphaNumeric(14); + o.Brand = f.Company.CompanyName(); + o.Year = f.Random.Int(1990, 2024); + o.Notes = f.Lorem.Sentence(); + o.ImageUrl = f.Internet.Url(); + }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task CollectibleResourceSearch_FiltersByTitle_IsOk() + { + await Authenticate(); + + var title = System.Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?search={title}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task CollectibleResourceOwnedAndFavoriteFilters_OnlyReturnMatchingItems_IsOk() + { + await Authenticate(); + + var title = $"OwnedTarget-{System.Guid.NewGuid():N}"; + var owned = await PostAsync($"/{ResourceEndpoint}", new CollectibleDto + { + Title = title, + // "owned" is derived from having at least one owned version, not a stored flag + OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, Price = 42.50m, ProductName = "Ultimate Collector's Edition" }] + }); + var favorite = await PostAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title, IsFavorite = true }); + var plain = await PostAsync($"/{ResourceEndpoint}", new CollectibleDto { Title = title }); + + try + { + var ownedResults = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); + ownedResults.Items.Should().ContainSingle(x => x.Id == owned.Id); + ownedResults.Items.Should().NotContain(x => x.Id == plain.Id); + // the version's fields must survive the full DTO -> model -> BSON round trip, including ProductName + ownedResults.Items.Single(x => x.Id == owned.Id).OwnedVersions.Should().BeEquivalentTo(owned.OwnedVersions); + + var favoriteResults = await GetAsync>($"/{ResourceEndpoint}?IsFavorite=true&search={title}"); + favoriteResults.Items.Should().ContainSingle(x => x.Id == favorite.Id); + favoriteResults.Items.Should().NotContain(x => x.Id == plain.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{owned.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{favorite.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{plain.Id}"); + } + } +} diff --git a/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs b/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs new file mode 100644 index 00000000..919bc2b9 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs @@ -0,0 +1,114 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Bogus; +using Keeptrack.Common.System; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Basic full-cycle CRUD coverage for Gear, same shape as +/// (Gear has no external reference-data provider either). +/// +public class GearResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/gear"; + + [Fact] + public async Task GearResourceFullCycle_IsOk() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + + await Authenticate(); + + var input = new Faker() + .Rules((f, o) => + { + o.Title = f.Random.AlphaNumeric(14); + o.Brand = f.Company.CompanyName(); + o.Year = f.Random.Int(1990, 2024); + o.Notes = f.Lorem.Sentence(); + o.ImageUrl = f.Internet.Url(); + }) + .Generate(); + var created = await PostAsync($"/{ResourceEndpoint}", input); + created.Id.Should().NotBeNullOrEmpty(); + + try + { + created.Title = "New shiny title"; + await PutAsync($"/{ResourceEndpoint}/{created.Id}", created); + + var updated = await GetAsync($"/{ResourceEndpoint}/{created.Id}"); + updated.Should().BeEquivalentTo(created); + + var finalItems = await GetAsync>($"/{ResourceEndpoint}"); + var firstItem = finalItems.Items.FirstOrDefault(x => x.Id == updated.Id); + firstItem.Should().NotBeNull(); + firstItem.Title.Should().Be(updated.Title); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task GearResourceSearch_FiltersByTitle_IsOk() + { + await Authenticate(); + + var title = System.Guid.NewGuid().ToString(); + var created = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?search={title}"); + results.Items.Should().ContainSingle(x => x.Id == created.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{created.Id}"); + } + } + + [Fact] + public async Task GearResourceOwnedAndFavoriteFilters_OnlyReturnMatchingItems_IsOk() + { + await Authenticate(); + + var title = $"OwnedTarget-{System.Guid.NewGuid():N}"; + var owned = await PostAsync($"/{ResourceEndpoint}", new GearDto + { + Title = title, + // "owned" is derived from having at least one owned version, not a stored flag + OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, Price = 199.99m, ProductName = "Limited edition" }] + }); + var favorite = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, IsFavorite = true }); + var plain = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + + try + { + var ownedResults = await GetAsync>($"/{ResourceEndpoint}?IsOwned=true&search={title}"); + ownedResults.Items.Should().ContainSingle(x => x.Id == owned.Id); + ownedResults.Items.Should().NotContain(x => x.Id == plain.Id); + // the version's fields must survive the full DTO -> model -> BSON round trip, including ProductName + ownedResults.Items.Single(x => x.Id == owned.Id).OwnedVersions.Should().BeEquivalentTo(owned.OwnedVersions); + + var favoriteResults = await GetAsync>($"/{ResourceEndpoint}?IsFavorite=true&search={title}"); + favoriteResults.Items.Should().ContainSingle(x => x.Id == favorite.Id); + favoriteResults.Items.Should().NotContain(x => x.Id == plain.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{owned.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{favorite.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{plain.Id}"); + } + } +} diff --git a/test/WebApi.UnitTests/Controllers/FreeTierTest.cs b/test/WebApi.UnitTests/Controllers/FreeTierTest.cs index e9750def..0434f941 100644 --- a/test/WebApi.UnitTests/Controllers/FreeTierTest.cs +++ b/test/WebApi.UnitTests/Controllers/FreeTierTest.cs @@ -155,6 +155,8 @@ public async Task Post_NeverCapsAnAdmin() [InlineData(typeof(HouseHistoryController), "MemberOnly")] [InlineData(typeof(HealthProfileController), "MemberOnly")] [InlineData(typeof(HealthRecordController), "MemberOnly")] + [InlineData(typeof(CollectibleController), "MemberOnly")] + [InlineData(typeof(GearController), "MemberOnly")] [InlineData(typeof(TvTimeImportController), "MemberOnly")] [InlineData(typeof(CarHistoryImportController), "MemberOnly")] [InlineData(typeof(HealthImportController), "MemberOnly")] From 7daf97f353b3ae83ec729f2bddbc636dfab7f403 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 00:07:20 +0200 Subject: [PATCH 13/35] Fix Collectible/Gear DI crash and rework cover image display The new API clients were never registered in BlazorApp's AddWebApiHttpClient, so opening Collectibles or Gear threw InvalidOperationException at render time - a registration this codebase keeps in a separate file from where the client classes live, which the earlier implementation missed entirely. Also replaces the cramped ItemThumb-next-to-title treatment on detail pages (Car/House/HealthProfile/Collectible/Gear) with the same full-width banner-image pattern VideoGameDetail/AlbumDetail already use. On Car specifically, the banner and URL field now sit below the energy type buttons rather than above the other fields. --- .../Components/Inventory/Pages/CarDetail.razor | 15 ++++++++++----- .../Inventory/Pages/CollectibleDetail.razor | 8 +++++++- .../Components/Inventory/Pages/GearDetail.razor | 8 +++++++- .../Inventory/Pages/HealthProfileDetail.razor | 13 +++++++++---- .../Components/Inventory/Pages/HouseDetail.razor | 13 +++++++++---- .../InfrastructureServiceCollectionExtensions.cs | 4 ++++ 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index fa4a06c5..b567692e 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -24,7 +24,6 @@ else
-
@@ -47,10 +46,6 @@ else -
- - -
@@ -62,6 +57,16 @@ else }
+ @if (!string.IsNullOrEmpty(Car.ImageUrl)) + { +
+ @Car.Name +
+ } +
+ + +
@if (HasMetricsToShow) diff --git a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor index 8b54b0cd..7aca8469 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor @@ -24,7 +24,6 @@ else
-
@@ -32,6 +31,13 @@ else
+ @if (!string.IsNullOrEmpty(Collectible.ImageUrl)) + { +
+ @Collectible.Title cover +
+ } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor index dc0e6444..e125cce6 100644 --- a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor @@ -24,7 +24,6 @@ else
-
@@ -32,6 +31,13 @@ else
+ @if (!string.IsNullOrEmpty(Item.ImageUrl)) + { +
+ @Item.Title cover +
+ } +
diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 6fce3c63..4bce6a55 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -24,7 +24,6 @@ else
-
@@ -35,10 +34,16 @@ else
-
- - +
+ @if (!string.IsNullOrEmpty(Profile.ImageUrl)) + { +
+ @Profile.Name
+ } +
+ +
diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index 61c09d7f..dfd46eea 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -24,7 +24,6 @@ else
-
@@ -54,10 +53,16 @@ else
-
- - +
+ @if (!string.IsNullOrEmpty(House.ImageUrl)) + { +
+ @House.Name
+ } +
+ +
diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index 273ea2e3..a4756214 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -21,6 +21,10 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) From 2afead763d86e4624bfca2d6ee38391daa0bfaf2 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 00:15:50 +0200 Subject: [PATCH 14/35] Use wide list thumbnails and a consistent separate banner card everywhere List pages (Cars/Houses/Collectibles/Gear) now pass ItemImageShape="wide" like VideoGames.razor, instead of the default portrait shape that badly cropped landscape photos of cars/houses/objects. HealthProfile keeps the portrait default since its subject is a person. Detail pages (Car/House/HealthProfile) now render the cover image as its own standalone kt-form-card p-0 banner, matching what Collectible/Gear already do (and what VideoGameDetail/AlbumDetail established) - not crammed inside the multi-field card after the last text field. --- .../Inventory/Pages/CarDetail.razor | 13 +++++++------ .../Components/Inventory/Pages/Cars.razor | 1 + .../Inventory/Pages/Collectibles.razor | 1 + .../Components/Inventory/Pages/Gear.razor | 1 + .../Inventory/Pages/HealthProfileDetail.razor | 19 ++++++++++--------- .../Inventory/Pages/HouseDetail.razor | 19 ++++++++++--------- .../Components/Inventory/Pages/Houses.razor | 1 + 7 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index b567692e..8f784f65 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -57,18 +57,19 @@ else }
- @if (!string.IsNullOrEmpty(Car.ImageUrl)) - { -
- @Car.Name -
- }
+ @if (!string.IsNullOrEmpty(Car.ImageUrl)) + { +
+ @Car.Name +
+ } + @if (HasMetricsToShow) { var metrics = Metrics!; diff --git a/src/BlazorApp/Components/Inventory/Pages/Cars.razor b/src/BlazorApp/Components/Inventory/Pages/Cars.razor index 7d86325f..ae982c61 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Cars.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Cars.razor @@ -18,6 +18,7 @@ Form="@_form" ItemTitle="@(car => car.Name)" ItemImageUrl="@(car => car.ImageUrl)" + ItemImageShape="wide" OnSearchKeyUp="@OnSearchKeyUp" OnClearSearch="@ClearSearch" OnGoToPage="@GoToPage" diff --git a/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor b/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor index 742b88f8..b4377ce1 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Collectibles.razor @@ -18,6 +18,7 @@ Form="@_form" ItemTitle="@(collectible => collectible.Title)" ItemImageUrl="@(collectible => collectible.ImageUrl)" + ItemImageShape="wide" OnSearchKeyUp="@OnSearchKeyUp" OnClearSearch="@ClearSearch" OnGoToPage="@GoToPage" diff --git a/src/BlazorApp/Components/Inventory/Pages/Gear.razor b/src/BlazorApp/Components/Inventory/Pages/Gear.razor index d7f270d1..a4cb5a93 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Gear.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Gear.razor @@ -18,6 +18,7 @@ Form="@_form" ItemTitle="@(gear => gear.Title)" ItemImageUrl="@(gear => gear.ImageUrl)" + ItemImageShape="wide" OnSearchKeyUp="@OnSearchKeyUp" OnClearSearch="@ClearSearch" OnGoToPage="@GoToPage" diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 4bce6a55..03be38e6 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -28,22 +28,23 @@ else + @if (!string.IsNullOrEmpty(Profile.ImageUrl)) + { +
+ @Profile.Name +
+ } +
-
- @if (!string.IsNullOrEmpty(Profile.ImageUrl)) - { -
- @Profile.Name +
+ +
- } -
- -
diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index dfd46eea..ce32a167 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -35,6 +35,13 @@ else }
+ @if (!string.IsNullOrEmpty(House.ImageUrl)) + { +
+ @House.Name +
+ } +
@@ -53,16 +60,10 @@ else
-
- @if (!string.IsNullOrEmpty(House.ImageUrl)) - { -
- @House.Name +
+ +
- } -
- -
diff --git a/src/BlazorApp/Components/Inventory/Pages/Houses.razor b/src/BlazorApp/Components/Inventory/Pages/Houses.razor index 6d481d8b..fcdb5fae 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Houses.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Houses.razor @@ -18,6 +18,7 @@ Form="@_form" ItemTitle="@(house => house.Name)" ItemImageUrl="@(house => house.ImageUrl)" + ItemImageShape="wide" OnSearchKeyUp="@OnSearchKeyUp" OnClearSearch="@ClearSearch" OnGoToPage="@GoToPage" From ba9732095dcb82387ac2bf5a3af385808b3817c5 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 00:32:04 +0200 Subject: [PATCH 15/35] UI fixes and new tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moved SetFieldAsync (fill + blur, for @onchange-bound fields) off BookDetailPage onto the shared DetailPageBase — it was never Book-specific, and I'd compounded the existing wart by copying BookDetailPage.SetFieldAsync into five new unrelated tests. Also fixed the three pre-existing misuses (BookSmokeTest, OwnershipSmokeTest, QuickAddSmokeTest) while I was in there. - Found ReferenceableDetailPageBase already had a CoverImage locator (role=img, alt ending in "cover"/"poster") for the reference-linked types. Promoted it to DetailPageBase instead of reinventing it, added ImageUrlInput alongside it, and fixed Car/House/HealthProfile's alt text (was just @Name, now @Name cover) so they actually match that convention. - Rewrote all five smoke tests (Car, House, Health, Collectible, Gear) to use detail.ImageUrlInput / detail.CoverImage / DetailPageBase.SetFieldAsync instead of raw inline selectors. --- .../Inventory/Pages/CarDetail.razor | 16 +++--- .../Inventory/Pages/CollectibleDetail.razor | 2 +- .../Inventory/Pages/GearDetail.razor | 2 +- .../Inventory/Pages/HealthProfileDetail.razor | 4 +- .../Inventory/Pages/HealthProfiles.razor | 1 + .../Inventory/Pages/HouseDetail.razor | 4 +- .../Pages/BookDetailPage.cs | 6 --- .../Pages/CollectibleDetailPage.cs | 8 +++ .../Pages/DetailPageBase.cs | 41 +++++++++++++-- .../Pages/GearDetailPage.cs | 8 +++ .../Pages/PageBase.cs | 4 ++ .../Pages/ReferenceableDetailPageBase.cs | 9 +--- .../Smoke/BookSmokeTest.cs | 2 +- .../Smoke/CarSmokeTest.cs | 8 +++ .../Smoke/CollectibleSmokeTest.cs | 52 +++++++++++++++++++ .../Smoke/GearSmokeTest.cs | 47 +++++++++++++++++ .../Smoke/HealthSmokeTest.cs | 6 +++ .../Smoke/HouseSmokeTest.cs | 8 +++ .../Smoke/OwnershipSmokeTest.cs | 8 +-- .../Smoke/QuickAddSmokeTest.cs | 8 +-- 20 files changed, 203 insertions(+), 41 deletions(-) create mode 100644 test/BlazorApp.PlaywrightTests/Pages/CollectibleDetailPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Pages/GearDetailPage.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/CollectibleSmokeTest.cs create mode 100644 test/BlazorApp.PlaywrightTests/Smoke/GearSmokeTest.cs diff --git a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor index 8f784f65..e55e6451 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CarDetail.razor @@ -28,6 +28,13 @@ else
+ @if (!string.IsNullOrEmpty(Car.ImageUrl)) + { +
+ @Car.Name cover +
+ } +
@@ -59,17 +66,10 @@ else
- +
- @if (!string.IsNullOrEmpty(Car.ImageUrl)) - { -
- @Car.Name -
- } - @if (HasMetricsToShow) { var metrics = Metrics!; diff --git a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor index 7aca8469..d2857099 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor @@ -54,7 +54,7 @@ else
- +
diff --git a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor index e125cce6..bf4d70db 100644 --- a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor @@ -54,7 +54,7 @@ else
- +
diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor index 03be38e6..99cc1f97 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfileDetail.razor @@ -31,7 +31,7 @@ else @if (!string.IsNullOrEmpty(Profile.ImageUrl)) {
- @Profile.Name + @Profile.Name cover
} @@ -43,7 +43,7 @@ else
- +
diff --git a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor index bf6c65de..c2616c43 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HealthProfiles.razor @@ -18,6 +18,7 @@ Form="@_form" ItemTitle="@(profile => profile.Name)" ItemImageUrl="@(profile => profile.ImageUrl)" + ItemImageShape="wide" OnSearchKeyUp="@OnSearchKeyUp" OnClearSearch="@ClearSearch" OnGoToPage="@GoToPage" diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index ce32a167..bcc70d80 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -38,7 +38,7 @@ else @if (!string.IsNullOrEmpty(House.ImageUrl)) {
- @House.Name + @House.Name cover
} @@ -62,7 +62,7 @@ else
- +
diff --git a/test/BlazorApp.PlaywrightTests/Pages/BookDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/BookDetailPage.cs index 17a24c1c..418f8e06 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/BookDetailPage.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/BookDetailPage.cs @@ -16,10 +16,4 @@ public class BookDetailPage(IPage page) : ReferenceableDetailPageBase(page) ///
public async Task SelectProviderAsync(string displayName) => await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = displayName, Exact = true }).ClickAsync(); - - public static async Task SetFieldAsync(ILocator input, string value) - { - await input.FillAsync(value); - await input.BlurAsync(); - } } diff --git a/test/BlazorApp.PlaywrightTests/Pages/CollectibleDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/CollectibleDetailPage.cs new file mode 100644 index 00000000..a3a4b767 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Pages/CollectibleDetailPage.cs @@ -0,0 +1,8 @@ +using Microsoft.Playwright; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; + +/// +/// Collectibles have no reference-data concept, same as - a plain . +/// +public class CollectibleDetailPage(IPage page) : DetailPageBase(page); diff --git a/test/BlazorApp.PlaywrightTests/Pages/DetailPageBase.cs b/test/BlazorApp.PlaywrightTests/Pages/DetailPageBase.cs index c7a7f60f..6ba6698a 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/DetailPageBase.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/DetailPageBase.cs @@ -1,22 +1,53 @@ +using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Playwright; namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; /// -/// Shared shape for every item's own detail page (Book/Movie/TvShow/VideoGame/Album/Playlist/Car/House) - -/// every one of them renders its title/name in an <input class="kt-title-input">, a class -/// unique enough on the page to locate without any data-testid (unlike the list pages' Add-form -/// fields, which needed one since multiple plain .form-control inputs share no distinguishing class). +/// Shared shape for every item's own detail page (Book/Movie/TvShow/VideoGame/Album/Playlist/Car/House/ +/// HealthProfile/Collectible/Gear) - every one of them renders its title/name in an +/// <input class="kt-title-input">, a class unique enough on the page to locate without any +/// data-testid (unlike the list pages' Add-form fields, which needed one since multiple plain +/// .form-control inputs share no distinguishing class). /// -public abstract class DetailPageBase(IPage page) : PageBase(page) +public abstract partial class DetailPageBase(IPage page) : PageBase(page) { + [GeneratedRegex("(cover|poster)$")] + private static partial Regex CoverRegex(); + public ILocator TitleInput => Page.Locator(".kt-title-input"); + /// + /// The cover-image banner, when the page has one - every detail page's own image element ends its + /// alt text in "cover" (reference-hydrated types) or "poster" by the same convention. Not every + /// subclass renders one (Playlist has no cover concept), so this simply never resolves on those. + /// + public ILocator CoverImage => Page.GetByRole(AriaRole.Img, new PageGetByRoleOptions { NameRegex = CoverRegex() }); + + /// + /// The plain "Cover image URL" field on the five non-reference-linked types (Car/House/HealthProfile/ + /// Collectible/Gear) - data-testid="image-url-input", since the label/input pair has no + /// for/id association. Book/Movie/TvShow/VideoGame/Album's reference-hydrated equivalent + /// (CustomImageUrl) is a separate, not-yet-testid'd field, out of scope here. + /// + public ILocator ImageUrlInput => Page.GetByTestId("image-url-input"); + public override async Task WaitForReadyAsync() { await base.WaitForReadyAsync(); await Assertions.Expect(Page.Locator(".kt-spinner")).ToBeHiddenAsync(); await Assertions.Expect(TitleInput).ToBeVisibleAsync(); } + + /// + /// Fills a detail-page field bound via @onchange (not @bind) and blurs it - Blazor's + /// @onchange listens for the native "change" event, which only fires on blur once the value has + /// actually changed, not on Playwright's FillAsync alone (that only dispatches "input"). + /// + public static async Task SetFieldAsync(ILocator input, string value) + { + await input.FillAsync(value); + await input.BlurAsync(); + } } diff --git a/test/BlazorApp.PlaywrightTests/Pages/GearDetailPage.cs b/test/BlazorApp.PlaywrightTests/Pages/GearDetailPage.cs new file mode 100644 index 00000000..ee82b8b8 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Pages/GearDetailPage.cs @@ -0,0 +1,8 @@ +using Microsoft.Playwright; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; + +/// +/// Gear has no reference-data concept, same as - a plain . +/// +public class GearDetailPage(IPage page) : DetailPageBase(page); diff --git a/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs b/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs index bf53a8f4..9526aa7f 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/PageBase.cs @@ -93,5 +93,9 @@ private async Task NavigateAsync(string linkName, TPage next) wher public Task OpenHealthAsync() => NavigateAsync("Health", new ListPage(Page, "/health", "Health")); + public Task OpenCollectiblesAsync() => NavigateAsync("Collectibles", new ListPage(Page, "/collectibles", "Collectibles")); + + public Task OpenGearAsync() => NavigateAsync("Gear", new ListPage(Page, "/gear", "Gear")); + public Task LogoutAsync() => NavigateAsync("Log out", new HomePage(Page)); } diff --git a/test/BlazorApp.PlaywrightTests/Pages/ReferenceableDetailPageBase.cs b/test/BlazorApp.PlaywrightTests/Pages/ReferenceableDetailPageBase.cs index c5a5396d..975fae56 100644 --- a/test/BlazorApp.PlaywrightTests/Pages/ReferenceableDetailPageBase.cs +++ b/test/BlazorApp.PlaywrightTests/Pages/ReferenceableDetailPageBase.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.Playwright; @@ -8,18 +7,14 @@ namespace Keeptrack.BlazorApp.PlaywrightTests.Pages; /// Shared shape for the five detail pages that carry a reference-data concept (Book/Movie/TvShow/VideoGame/Album) - /// all five render the exact same "check for reference match" icon button + toast, /// and the exact same admin-only InlineReferenceLinker (search the real provider, pick a candidate, click "Link"). +/// is inherited from - shared with the non-reference-linked types too. /// -public abstract partial class ReferenceableDetailPageBase(IPage page) : DetailPageBase(page) +public abstract class ReferenceableDetailPageBase(IPage page) : DetailPageBase(page) { - [GeneratedRegex("(cover|poster)$")] - private static partial Regex CoverRegex(); - private ILocator RefreshReferenceButton => Page.Locator("button.kt-icon-btn"); private ILocator ReferenceToast => Page.Locator(".kt-inline-toast"); - public ILocator CoverImage => Page.GetByRole(AriaRole.Img, new PageGetByRoleOptions { NameRegex = CoverRegex() }); - /// /// The non-admin, local-only-lookup "check for reference match" icon button - never calls a real provider, only used against pre-seeded/already-resolved data. /// diff --git a/test/BlazorApp.PlaywrightTests/Smoke/BookSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/BookSmokeTest.cs index 5d0af27b..6f0f6e74 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/BookSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/BookSmokeTest.cs @@ -30,7 +30,7 @@ public async Task AddEditAndDelete_BookThroughTheList() await detail.WaitForReadyAsync(); await Assertions.Expect(detail.TitleInput).ToHaveValueAsync(title); - await BookDetailPage.SetFieldAsync(detail.SeriesInput, Series); + await DetailPageBase.SetFieldAsync(detail.SeriesInput, Series); // Round-tripping via the list (rather than a raw page reload) proves the edit persisted server-side just as well - // BookDetail.razor re-fetches via GetOneAsync on every navigation to it - diff --git a/test/BlazorApp.PlaywrightTests/Smoke/CarSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/CarSmokeTest.cs index b7fcd2d9..adf28031 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/CarSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/CarSmokeTest.cs @@ -17,6 +17,7 @@ public async Task AddAndDelete_CarThroughTheList() SkipIfReadOnly(); var name = $"E2e Smoke Car {Guid.NewGuid():N}"; + const string imageUrl = "https://picsum.photos/seed/e2e-car/600/300"; var home = await new HomePage(Page).OpenAsync(); var list = await home.OpenCarsAsync(); @@ -28,7 +29,14 @@ public async Task AddAndDelete_CarThroughTheList() await detail.WaitForReadyAsync(); await Assertions.Expect(detail.TitleInput).ToHaveValueAsync(name); + // cover image: the detail banner and the list's wide thumbnail (same shape as VideoGames.razor) + // must both pick up the URL - a plain default-portrait thumb was a reported regression here. + await DetailPageBase.SetFieldAsync(detail.ImageUrlInput, imageUrl); + await Assertions.Expect(detail.CoverImage).ToHaveAttributeAsync("src", imageUrl); + list = await detail.OpenCarsAsync(); + await Assertions.Expect(list.Row(name).Locator(".kt-item-thumb.wide img")).ToHaveAttributeAsync("src", imageUrl); + await list.DeleteAsync(name); await Assertions.Expect(list.Row(name)).Not.ToBeVisibleAsync(); } diff --git a/test/BlazorApp.PlaywrightTests/Smoke/CollectibleSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/CollectibleSmokeTest.cs new file mode 100644 index 00000000..9eda80ac --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/CollectibleSmokeTest.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Add/edit/delete coverage for the new Collectible type, same shape as . +/// Opening the detail page here is exactly the flow that regressed once already: the new type's API +/// client was added to the codebase but never registered in BlazorApp's DI container +/// (AddWebApiHttpClient), so navigating to the list and opening an item threw InvalidOperationException +/// at render time - a bug the build and every unit test missed, and only a real browser click surfaces. +/// This also covers the cover image round trip (fill the URL, see the detail banner and the list's wide +/// thumbnail both pick it up) - the other thing that shipped wrong on the first two attempts. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class CollectibleSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task AddEditAndDelete_CollectibleThroughTheList() + { + SkipIfReadOnly(); + + var title = $"E2e Smoke Collectible {Guid.NewGuid():N}"; + const string imageUrl = "https://picsum.photos/seed/e2e-collectible/600/300"; + + var home = await new HomePage(Page).OpenAsync(); + var list = await home.OpenCollectiblesAsync(); + await list.ClickAddAsync(); + await list.FillAsync("title-input", title); + await list.SaveNewAsync(); + + var detail = new CollectibleDetailPage(Page); + await detail.WaitForReadyAsync(); + await Assertions.Expect(detail.TitleInput).ToHaveValueAsync(title); + + await DetailPageBase.SetFieldAsync(detail.ImageUrlInput, imageUrl); + await Assertions.Expect(detail.CoverImage).ToHaveAttributeAsync("src", imageUrl); + + list = await detail.OpenCollectiblesAsync(); + // ItemImageShape="wide" (same as VideoGames.razor) - a plain default-portrait thumb was the + // reported regression, so this pins the actual rendered shape, not just that an image exists. + await Assertions.Expect(list.Row(title).Locator(".kt-item-thumb.wide img")).ToHaveAttributeAsync("src", imageUrl); + + await list.DeleteAsync(title); + await Assertions.Expect(list.Row(title)).Not.ToBeVisibleAsync(); + } +} diff --git a/test/BlazorApp.PlaywrightTests/Smoke/GearSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/GearSmokeTest.cs new file mode 100644 index 00000000..afc772a7 --- /dev/null +++ b/test/BlazorApp.PlaywrightTests/Smoke/GearSmokeTest.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; +using Keeptrack.BlazorApp.PlaywrightTests.Hosting; +using Keeptrack.BlazorApp.PlaywrightTests.Pages; +using Microsoft.Playwright; +using Xunit; + +namespace Keeptrack.BlazorApp.PlaywrightTests.Smoke; + +/// +/// Add/edit/delete coverage for the new Gear type - see for why opening +/// the detail page (not just building/unit-testing) is the point of this test. +/// +[Trait("Category", "E2eTests")] +[Trait("Mode", "Mutating")] +public class GearSmokeTest(End2EndFixture fixture) : SmokeTestBase(fixture) +{ + [Fact] + public async Task AddEditAndDelete_GearThroughTheList() + { + SkipIfReadOnly(); + + var title = $"E2e Smoke Gear {Guid.NewGuid():N}"; + const string imageUrl = "https://picsum.photos/seed/e2e-gear/600/300"; + + var home = await new HomePage(Page).OpenAsync(); + var list = await home.OpenGearAsync(); + await list.ClickAddAsync(); + await list.FillAsync("title-input", title); + await list.SaveNewAsync(); + + var detail = new GearDetailPage(Page); + await detail.WaitForReadyAsync(); + await Assertions.Expect(detail.TitleInput).ToHaveValueAsync(title); + + await DetailPageBase.SetFieldAsync(detail.ImageUrlInput, imageUrl); + await Assertions.Expect(detail.CoverImage).ToHaveAttributeAsync("src", imageUrl); + + list = await detail.OpenGearAsync(); + // ItemImageShape="wide" (same as VideoGames.razor) - a plain default-portrait thumb was the + // reported regression, so this pins the actual rendered shape, not just that an image exists. + await Assertions.Expect(list.Row(title).Locator(".kt-item-thumb.wide img")).ToHaveAttributeAsync("src", imageUrl); + + await list.DeleteAsync(title); + await Assertions.Expect(list.Row(title)).Not.ToBeVisibleAsync(); + } +} diff --git a/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs index d3ab9ffc..e68340ea 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/HealthSmokeTest.cs @@ -33,6 +33,12 @@ public async Task AddProfileAndUnbalancedAppointment_ThenDelete() await detail.WaitForReadyAsync(); await Assertions.Expect(detail.TitleInput).ToHaveValueAsync(name); + // cover image: HealthProfile keeps the default portrait list shape (a person's photo, unlike + // Car/House/Collectible/Gear's "wide" objects/spaces) - just the detail banner round trip here. + const string imageUrl = "https://picsum.photos/seed/e2e-health/300/400"; + await DetailPageBase.SetFieldAsync(detail.ImageUrlInput, imageUrl); + await Assertions.Expect(detail.CoverImage).ToHaveAttributeAsync("src", imageUrl); + // add an appointment paid 60 with nothing reimbursed yet - it must come back flagged await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add entry" }).ClickAsync(); // data-testid, not GetByLabel: the modal's label/input pairs have no for/id association (the diff --git a/test/BlazorApp.PlaywrightTests/Smoke/HouseSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/HouseSmokeTest.cs index 27cd81be..667625d8 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/HouseSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/HouseSmokeTest.cs @@ -17,6 +17,7 @@ public async Task AddAndDelete_HouseThroughTheList() SkipIfReadOnly(); var name = $"E2e Smoke House {Guid.NewGuid():N}"; + const string imageUrl = "https://picsum.photos/seed/e2e-house/600/300"; var home = await new HomePage(Page).OpenAsync(); var list = await home.OpenHousesAsync(); @@ -28,7 +29,14 @@ public async Task AddAndDelete_HouseThroughTheList() await detail.WaitForReadyAsync(); await Assertions.Expect(detail.TitleInput).ToHaveValueAsync(name); + // cover image: the detail banner and the list's wide thumbnail (same shape as VideoGames.razor) + // must both pick up the URL - a plain default-portrait thumb was a reported regression here. + await DetailPageBase.SetFieldAsync(detail.ImageUrlInput, imageUrl); + await Assertions.Expect(detail.CoverImage).ToHaveAttributeAsync("src", imageUrl); + list = await detail.OpenHousesAsync(); + await Assertions.Expect(list.Row(name).Locator(".kt-item-thumb.wide img")).ToHaveAttributeAsync("src", imageUrl); + await list.DeleteAsync(name); await Assertions.Expect(list.Row(name)).Not.ToBeVisibleAsync(); } diff --git a/test/BlazorApp.PlaywrightTests/Smoke/OwnershipSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/OwnershipSmokeTest.cs index dc65938e..06e0c88e 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/OwnershipSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/OwnershipSmokeTest.cs @@ -41,10 +41,10 @@ public async Task AddingAndRemovingAnOwnedVersion_DrivesTheOwnedState() // add a version (defaults to Physical), fill in its purchase details, and save the draft await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "+ Add version" }).ClickAsync(); - await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-price-input"), "12.50"); - await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-acquired-input"), "2024-05-17"); - await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-vendor-input"), "E2e Bookshop"); - await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-reference-input"), "Paperback 2nd edition"); + await DetailPageBase.SetFieldAsync(Page.GetByTestId("version-price-input"), "12.50"); + await DetailPageBase.SetFieldAsync(Page.GetByTestId("version-acquired-input"), "2024-05-17"); + await DetailPageBase.SetFieldAsync(Page.GetByTestId("version-vendor-input"), "E2e Bookshop"); + await DetailPageBase.SetFieldAsync(Page.GetByTestId("version-reference-input"), "Paperback 2nd edition"); await Page.GetByTestId("version-save-button").ClickAsync(); // round-trip via the list: the row must show the derived Owned badge, and reopening the diff --git a/test/BlazorApp.PlaywrightTests/Smoke/QuickAddSmokeTest.cs b/test/BlazorApp.PlaywrightTests/Smoke/QuickAddSmokeTest.cs index 6c312afe..4064d0ab 100644 --- a/test/BlazorApp.PlaywrightTests/Smoke/QuickAddSmokeTest.cs +++ b/test/BlazorApp.PlaywrightTests/Smoke/QuickAddSmokeTest.cs @@ -45,9 +45,9 @@ public async Task QuickAddMovie_WithAnOwnedCopy_LandsOnItsDetailPageWithTheCopyS var quickAdd = await home.OpenQuickAddAsync(); await quickAdd.SelectTypeAsync("movie"); - await BookDetailPage.SetFieldAsync(quickAdd.TitleInput, title); + await DetailPageBase.SetFieldAsync(quickAdd.TitleInput, title); await Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "I own a copy" }).ClickAsync(); - await BookDetailPage.SetFieldAsync(Page.GetByTestId("version-price-input"), "19.99"); + await DetailPageBase.SetFieldAsync(Page.GetByTestId("version-price-input"), "19.99"); await quickAdd.SaveButton.ClickAsync(); var detail = new MovieDetailPage(Page); @@ -82,8 +82,8 @@ public async Task QuickAddCarRecord_WithASingleExistingCar_PreselectsItAndSavesT await quickAdd.SelectTypeAsync("car"); // the tenant now has exactly one car - it's preselected silently, no segmented picker to click - await BookDetailPage.SetFieldAsync(Page.GetByTestId("mileage-input"), mileage.ToString()); - await BookDetailPage.SetFieldAsync(Page.GetByTestId("cost-input"), "65.40"); + await DetailPageBase.SetFieldAsync(Page.GetByTestId("mileage-input"), mileage.ToString()); + await DetailPageBase.SetFieldAsync(Page.GetByTestId("cost-input"), "65.40"); await quickAdd.SaveButton.ClickAsync(); var detail = new CarDetailPage(Page); From d57e7087bd72b8f152408e03d0fbe9e31dac9b5f Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 00:44:29 +0200 Subject: [PATCH 16/35] Improve google books api calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduced the failure directly (ran the smoke test 5+ times against the real Google Books API) and found two separate, real issues: 1. Google Books genuinely is having a rough day — retries already existed (3 retries via AddStandardResilienceHandler), but weren't always enough to ride out sustained 500/503s. Fixed: added AddBookProviderResilienceHandler (ProviderResilienceExtensions.cs) with 5 retries / shorter backoff / wider total timeout, applied to Google Books + Open Library + BnF in Program.cs. A run after this change took 32s (visibly working through retries) and passed. 2. Google Books responses were needlessly heavy — confirmed with curl: an unfiltered search response was ~75KB vs TMDB's ~14KB for a comparable query, explaining the "always slower than other providers" feel independent of today's outage. Fixed: added Google's fields= partial-response parameter to both the search and details calls in GoogleBooksClient.cs, restricted to exactly the fields the app actually reads. 3. Bonus UI bug found while reproducing: InlineReferenceLinker.razor showed "No results" and the raw HttpRequestException message simultaneously on a search failure, and the raw exception text wasn't user-friendly. Fixed both. --- .../InlineReferenceLinker.razor | 7 +++++-- .../ProviderResilienceExtensions.cs | 18 ++++++++++++++++++ src/WebApi/Program.cs | 6 +++--- src/WebApi/ReferenceData/GoogleBooksClient.cs | 19 +++++++++++++++++-- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor index bf676dd1..dc073242 100644 --- a/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor +++ b/src/BlazorApp/Components/ReferenceDataAdmin/InlineReferenceLinker.razor @@ -29,7 +29,7 @@ {
Searching…
} - else if (_searched && _results.Count == 0) + else if (_searched && _results.Count == 0 && _error is null) {
No @ProviderName results for this title/year.
} @@ -126,8 +126,11 @@ { // an uncaught exception here would crash the whole Blazor Server circuit (not just this // component), forcing a full page reload - same reasoning as LinkAsync's own try/catch below. + // The provider's own exchange (which HTTP client, which status code) is an implementation + // detail the admin shouldn't have to parse out of a raw HttpRequestException message - + // "Search failed" plus a retry hint says the one thing that's actually actionable. _results = []; - _error = ex.Message; + _error = $"{ProviderName} search failed ({ex.Message}). This is usually transient - wait a moment and try again."; } finally { diff --git a/src/WebApi/DependencyInjection/ProviderResilienceExtensions.cs b/src/WebApi/DependencyInjection/ProviderResilienceExtensions.cs index 111af315..1d43d845 100644 --- a/src/WebApi/DependencyInjection/ProviderResilienceExtensions.cs +++ b/src/WebApi/DependencyInjection/ProviderResilienceExtensions.cs @@ -15,4 +15,22 @@ internal static class ProviderResilienceExtensions /// internal static void AddProviderResilienceHandler(this IHttpClientBuilder builder) => builder.AddStandardResilienceHandler(options => options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10)); + + /// + /// Same pipeline as , with a larger retry budget - confirmed + /// against the real API that Google Books returns a transient 500/503 noticeably more often than + /// TMDB/RAWG/Discogs, and 3 retries (the shared default) isn't always enough to ride it out. + /// Applied to all three book providers (not just Google Books) for consistency, since Open Library/BnF + /// share the same registry/fallback shape and there's no reason to special-case just one of the three. + /// The retry delay is shortened (1s base instead of the default 2s) so the extra attempts still fit + /// comfortably under the widened total budget instead of mostly being eaten by exponential backoff. + /// + internal static void AddBookProviderResilienceHandler(this IHttpClientBuilder builder) => + builder.AddStandardResilienceHandler(options => + { + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10); + options.Retry.MaxRetryAttempts = 5; + options.Retry.Delay = TimeSpan.FromSeconds(1); + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(40); + }); } diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 5a186896..4332b87f 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -70,20 +70,20 @@ { client.BaseAddress = new Uri("https://www.googleapis.com/books/v1/"); client.Timeout = Timeout.InfiniteTimeSpan; -}).AddProviderResilienceHandler(); +}).AddBookProviderResilienceHandler(); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://openlibrary.org/"); client.DefaultRequestHeaders.Add("User-Agent", "Keeptrack/1.0 (+https://github.com/devpro/keeptrack)"); client.Timeout = Timeout.InfiniteTimeSpan; -}).AddProviderResilienceHandler(); +}).AddBookProviderResilienceHandler(); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://catalogue.bnf.fr/api/"); client.Timeout = Timeout.InfiniteTimeSpan; -}).AddProviderResilienceHandler(); +}).AddBookProviderResilienceHandler(); builder.Services.AddTransient(sp => sp.GetRequiredService()); // a factory (not a plain AddScoped) so configuration.BookReferenceProvider - // a plain computed-on-access property, not cached - is read fresh on every scope, same "checked fresh" diff --git a/src/WebApi/ReferenceData/GoogleBooksClient.cs b/src/WebApi/ReferenceData/GoogleBooksClient.cs index 473fc22a..7c1b388d 100644 --- a/src/WebApi/ReferenceData/GoogleBooksClient.cs +++ b/src/WebApi/ReferenceData/GoogleBooksClient.cs @@ -40,10 +40,25 @@ public async Task> SearchBooksAsync(string title return results; } + /// + /// Google's own fields= partial-response parameter (https://developers.google.com/books/docs/v1/performance), + /// restricted to exactly what reads - confirmed against the real API that + /// the unfiltered response is ~75KB/20 results (a full volumeInfo, including the long HTML + /// description and every industry identifier, per item) against TMDB's ~14KB for a comparable movie + /// search; this is why a Google Books lookup was observed to be consistently slower than the other three + /// providers even on a successful call, not just during today's provider-side 500/503s. + /// + private const string SearchFields = "fields=items(id,volumeInfo(title,authors,publishedDate,imageLinks/thumbnail))"; + + /// + /// Same idea as , restricted to what reads. + /// + private const string DetailsFields = "fields=volumeInfo(title,publishedDate,description,authors,categories,imageLinks/thumbnail,language,industryIdentifiers)"; + private async Task> SearchBooksCoreAsync(string query, CancellationToken cancellationToken) { var response = await http.GetFromJsonAsync( - $"volumes?q={Encode(query)}&maxResults={MaxResults}&key={ApiKey}", cancellationToken); + $"volumes?q={Encode(query)}&maxResults={MaxResults}&key={ApiKey}&{SearchFields}", cancellationToken); return response?.Items .Where(i => !string.IsNullOrEmpty(i.Id) && !string.IsNullOrEmpty(i.VolumeInfo?.Title)) @@ -54,7 +69,7 @@ private async Task> SearchBooksCoreAsync(string public async Task GetBookDetailsAsync(string externalId, CancellationToken cancellationToken = default) { - var volume = await http.GetFromJsonAsync($"volumes/{externalId}?key={ApiKey}", cancellationToken); + var volume = await http.GetFromJsonAsync($"volumes/{externalId}?key={ApiKey}&{DetailsFields}", cancellationToken); var info = volume?.VolumeInfo; if (info?.Title is null) return null; From ba4cfbaf1c6414d4cf486276003ffd62a545447a Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 01:10:49 +0200 Subject: [PATCH 17/35] Add gear and collectible in amazon import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added Gear and Collectible as importable types in the Amazon order-history CSV import feature, mirroring the existing Movie/TvShow handling exactly (both use the same OwnedVersions shape): - AmazonImportMediaType — added Gear, Collectible enum members. - AmazonImportCommitResultDto — added Gear*/Collectibles* created/merged/skipped counts. - AmazonImportController — injected IGearRepository/ICollectibleRepository; extended Preview (already-imported check) and Commit (create/merge block) with two more cases. - AmazonImportPage.razor — added the two options to the type dropdown and two lines to the result summary. - Tests — extended AmazonFixtureCsvBuilder with Gear/Collectible fixture rows, and AmazonImportResourceTest with full round-trip assertions (preview, commit, re-import dedup, cleanup). - Updated a couple of doc comments (AmazonImportController, AmazonOwnedItemImportRequestItem) that used to enumerate only Book/Movie/TvShow/VideoGame. --- .../Components/Import/AmazonImportPage.razor | 6 ++- .../AmazonOwnedItemImportRequestItem.cs | 6 +-- .../Dto/AmazonImportCommitResultDto.cs | 18 +++++++ .../Dto/AmazonImportMediaType.cs | 4 +- .../Controllers/AmazonImportController.cs | 48 ++++++++++++++++++- .../Resources/AmazonFixtureCsvBuilder.cs | 10 ++++ .../Resources/AmazonImportResourceTest.cs | 44 ++++++++++++++++- 7 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/BlazorApp/Components/Import/AmazonImportPage.razor b/src/BlazorApp/Components/Import/AmazonImportPage.razor index bf116ee4..5b40492a 100644 --- a/src/BlazorApp/Components/Import/AmazonImportPage.razor +++ b/src/BlazorApp/Components/Import/AmazonImportPage.razor @@ -76,6 +76,8 @@ + + @@ -142,7 +144,9 @@

Books: @_commitResult.BooksCreated created, @_commitResult.BooksMergedInto merged into existing, @_commitResult.BooksSkipped already imported

Movies: @_commitResult.MoviesCreated created, @_commitResult.MoviesMergedInto merged into existing, @_commitResult.MoviesSkipped already imported

TV shows: @_commitResult.TvShowsCreated created, @_commitResult.TvShowsMergedInto merged into existing, @_commitResult.TvShowsSkipped already imported

-

Video games: @_commitResult.VideoGamesCreated created, @_commitResult.VideoGamesMergedInto merged into existing, @_commitResult.VideoGamesSkipped already imported

+

Video games: @_commitResult.VideoGamesCreated created, @_commitResult.VideoGamesMergedInto merged into existing, @_commitResult.VideoGamesSkipped already imported

+

Gear: @_commitResult.GearCreated created, @_commitResult.GearMergedInto merged into existing, @_commitResult.GearSkipped already imported

+

Collectibles: @_commitResult.CollectiblesCreated created, @_commitResult.CollectiblesMergedInto merged into existing, @_commitResult.CollectiblesSkipped already imported

} diff --git a/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs b/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs index d11586f1..d170795b 100644 --- a/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs +++ b/src/Domain/Models/AmazonOwnedItemImportRequestItem.cs @@ -2,8 +2,8 @@ namespace Keeptrack.Domain.Models; /// /// One user-selected/edited row from the review UI, already translated from the web contract - the input -/// to for the three -/// domains that use (Book, Movie, TvShow). See +/// to for the domains +/// that use (Book, Movie, TvShow, Gear, Collectible). See /// for VideoGame's own shape. /// public class AmazonOwnedItemImportRequestItem @@ -19,7 +19,7 @@ public class AmazonOwnedItemImportRequestItem public int? Year { get; set; } - /// Book-only (null for Movie/TvShow) - see . + /// Book-only (null for Movie/TvShow/Gear/Collectible) - see . public string? Isbn { get; set; } public required OwnedVersionModel OwnedVersion { get; set; } diff --git a/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs b/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs index eeb94151..03978605 100644 --- a/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs +++ b/src/WebApi.Contracts/Dto/AmazonImportCommitResultDto.cs @@ -41,4 +41,22 @@ public class AmazonImportCommitResultDto /// Rows whose order reference already matched an existing platform entry - not duplicated. public int VideoGamesSkipped { get; set; } + + /// Brand new gear items created. + public int GearCreated { get; set; } + + /// Existing gear items (including ones created earlier in this same commit) that received an additional owned version. + public int GearMergedInto { get; set; } + + /// Rows whose order reference already matched an existing owned version - not duplicated. + public int GearSkipped { get; set; } + + /// Brand new collectibles created. + public int CollectiblesCreated { get; set; } + + /// Existing collectibles (including ones created earlier in this same commit) that received an additional owned version. + public int CollectiblesMergedInto { get; set; } + + /// Rows whose order reference already matched an existing owned version - not duplicated. + public int CollectiblesSkipped { get; set; } } diff --git a/src/WebApi.Contracts/Dto/AmazonImportMediaType.cs b/src/WebApi.Contracts/Dto/AmazonImportMediaType.cs index c7c557a4..609d2691 100644 --- a/src/WebApi.Contracts/Dto/AmazonImportMediaType.cs +++ b/src/WebApi.Contracts/Dto/AmazonImportMediaType.cs @@ -9,5 +9,7 @@ public enum AmazonImportMediaType Book, Movie, TvShow, - VideoGame + VideoGame, + Gear, + Collectible } diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs index 131f8139..fd9acf42 100644 --- a/src/WebApi/Controllers/AmazonImportController.cs +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -11,7 +11,7 @@ namespace Keeptrack.WebApi.Controllers; /// /// Previews an Amazon.fr order-history export and commits the rows the user selected/edited in the review -/// UI as books, movies, TV shows, or video games (picked per row - see ). +/// UI as books, movies, TV shows, video games, gear, or collectibles (picked per row - see ). /// Synchronous on both ends: unlike the TV Time import, there is no external API call in the loop, so even /// a multi-year export completes well within a normal request. /// @@ -23,6 +23,8 @@ public class AmazonImportController( IMovieRepository movieRepository, ITvShowRepository tvShowRepository, IVideoGameRepository videoGameRepository, + IGearRepository gearRepository, + ICollectibleRepository collectibleRepository, AmazonOrderPreviewRowDtoMapper previewMapper) : ControllerBase { /// @@ -53,12 +55,16 @@ public async Task>> Preview(IFormFil var existingMovies = await FindAllAsync(movieRepository, ownerId, new MovieModel { OwnerId = ownerId, Title = string.Empty }); var existingTvShows = await FindAllAsync(tvShowRepository, ownerId, new TvShowModel { OwnerId = ownerId, Title = string.Empty }); var existingVideoGames = await FindAllAsync(videoGameRepository, ownerId, new VideoGameModel { OwnerId = ownerId, Title = string.Empty }); + var existingGear = await FindAllAsync(gearRepository, ownerId, new GearModel { OwnerId = ownerId, Title = string.Empty }); + var existingCollectibles = await FindAllAsync(collectibleRepository, ownerId, new CollectibleModel { OwnerId = ownerId, Title = string.Empty }); var alreadyImportedReferences = new HashSet(); alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingBooks, b => b.OwnedVersions.Select(v => v.Reference))); alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingMovies, m => m.OwnedVersions.Select(v => v.Reference))); alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingTvShows, t => t.OwnedVersions.Select(v => v.Reference))); alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingVideoGames, g => g.Platforms.Select(p => p.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingGear, g => g.OwnedVersions.Select(v => v.Reference))); + alreadyImportedReferences.UnionWith(OwnedItemImportMergeService.FindImportedReferences(existingCollectibles, c => c.OwnedVersions.Select(v => v.Reference))); await using var stream = file.OpenReadStream(); var rows = AmazonOrderPreviewService.BuildPreview(stream, alreadyImportedReferences); @@ -89,6 +95,8 @@ public async Task> Commit(AmazonImport var movieItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Movie).ToList(); var tvShowItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.TvShow).ToList(); var videoGameItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.VideoGame).ToList(); + var gearItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Gear).ToList(); + var collectibleItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Collectible).ToList(); foreach (var item in videoGameItems.Where(item => string.IsNullOrWhiteSpace(item.Platform))) { @@ -173,6 +181,44 @@ public async Task> Commit(AmazonImport (result.VideoGamesCreated, result.VideoGamesMergedInto, result.VideoGamesSkipped) = (created, mergedInto, skipped); } + if (gearItems.Count > 0) + { + var existingGear = await FindAllAsync(gearRepository, ownerId, new GearModel { OwnerId = ownerId, Title = string.Empty }); + var (created, mergedInto, skipped) = await CommitAsync( + gearRepository, existingGear, gearItems.Select(ToOwnedItemRequestItem).ToList(), + g => g.Title, g => g.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new GearModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + OwnedVersions = [item.OwnedVersion] + }, + (gear, item) => gear.OwnedVersions.Add(item.OwnedVersion), ownerId); + (result.GearCreated, result.GearMergedInto, result.GearSkipped) = (created, mergedInto, skipped); + } + + if (collectibleItems.Count > 0) + { + var existingCollectibles = await FindAllAsync(collectibleRepository, ownerId, new CollectibleModel { OwnerId = ownerId, Title = string.Empty }); + var (created, mergedInto, skipped) = await CommitAsync( + collectibleRepository, existingCollectibles, collectibleItems.Select(ToOwnedItemRequestItem).ToList(), + c => c.Title, c => c.OwnedVersions.Select(v => v.Reference), + i => i.Title, i => i.OwnedVersion.Reference, + item => new CollectibleModel + { + OwnerId = ownerId, + Title = item.Title, + Year = item.Year, + Notes = AmazonImportMergeService.BuildAmazonProvenanceNotes(item.AmazonTitle, null), + OwnedVersions = [item.OwnedVersion] + }, + (collectible, item) => collectible.OwnedVersions.Add(item.OwnedVersion), ownerId); + (result.CollectiblesCreated, result.CollectiblesMergedInto, result.CollectiblesSkipped) = (created, mergedInto, skipped); + } + return Ok(result); } diff --git a/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs b/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs index 61d7e8a6..be721cb6 100644 --- a/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs +++ b/test/WebApi.IntegrationTests/Resources/AmazonFixtureCsvBuilder.cs @@ -38,6 +38,14 @@ internal static class AmazonFixtureCsvBuilder private const string VideoGameAsin = "B000000004"; public const string VideoGameOrderId = "999-5555555-5555555"; + public const string GearTitle = "Keeptrack Amazon Import Test Gear"; + private const string GearAsin = "B000000005"; + public const string GearOrderId = "999-8888888-8888888"; + + public const string CollectibleTitle = "Keeptrack Amazon Import Test Collectible"; + private const string CollectibleAsin = "B000000006"; + public const string CollectibleOrderId = "999-9999999-9999999"; + public static byte[] Build() { var csv = $""" @@ -47,6 +55,8 @@ public static byte[] Build() {MovieAsin},2019-05-01T10:00:00Z,{MovieOrderId},{MovieTitle},New,12.99,Amazon.fr {TvShowAsin},2018-03-15T10:00:00Z,{TvShowOrderId},{TvShowTitle},New,29.99,Amazon.fr {VideoGameAsin},2021-07-20T10:00:00Z,{VideoGameOrderId},{VideoGameTitle},New,49.99,Amazon.fr + {GearAsin},2022-02-10T10:00:00Z,{GearOrderId},{GearTitle},New,89.99,Amazon.fr + {CollectibleAsin},2023-11-05T10:00:00Z,{CollectibleOrderId},{CollectibleTitle},New,39.99,Amazon.fr """; return Encoding.UTF8.GetBytes(csv); diff --git a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs index e560837b..43089d3c 100644 --- a/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/AmazonImportResourceTest.cs @@ -33,10 +33,14 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl var movieRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.MovieTitle).Subject; var tvShowRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.TvShowTitle).Subject; var videoGameRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.VideoGameTitle).Subject; - // none of these three have an ISBN-shaped ASIN - confirms the heuristic never mistakes them for books + var gearRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.GearTitle).Subject; + var collectibleRow = preview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.CollectibleTitle).Subject; + // none of these have an ISBN-shaped ASIN - confirms the heuristic never mistakes them for books movieRow.LooksLikeBook.Should().BeFalse(); tvShowRow.LooksLikeBook.Should().BeFalse(); videoGameRow.LooksLikeBook.Should().BeFalse(); + gearRow.LooksLikeBook.Should().BeFalse(); + collectibleRow.LooksLikeBook.Should().BeFalse(); try { @@ -57,7 +61,9 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl ToCommitItem(bookRow, AmazonImportMediaType.Book, isbn: bookRow.SuggestedIsbn, year: 1997), ToCommitItem(movieRow, AmazonImportMediaType.Movie), ToCommitItem(tvShowRow, AmazonImportMediaType.TvShow), - ToCommitItem(videoGameRow, AmazonImportMediaType.VideoGame, platform: "PS5") + ToCommitItem(videoGameRow, AmazonImportMediaType.VideoGame, platform: "PS5"), + ToCommitItem(gearRow, AmazonImportMediaType.Gear), + ToCommitItem(collectibleRow, AmazonImportMediaType.Collectible) ] }; @@ -66,6 +72,8 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl commitResult.MoviesCreated.Should().Be(1); commitResult.TvShowsCreated.Should().Be(1); commitResult.VideoGamesCreated.Should().Be(1); + commitResult.GearCreated.Should().Be(1); + commitResult.CollectiblesCreated.Should().Be(1); var books = await GetAsync>($"/api/books?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.BookTitle)}"); var book = books.Items.Should().ContainSingle().Subject; @@ -91,6 +99,16 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl videoGame.Platforms[0].Platform.Should().Be("PS5"); videoGame.Platforms[0].Reference.Should().Contain(AmazonFixtureCsvBuilder.VideoGameOrderId); + var gearList = await GetAsync>($"/api/gear?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.GearTitle)}"); + var gear = gearList.Items.Should().ContainSingle().Subject; + gear.OwnedVersions.Should().ContainSingle(); + gear.OwnedVersions[0].Reference.Should().Contain(AmazonFixtureCsvBuilder.GearOrderId); + + var collectibles = await GetAsync>($"/api/collectibles?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.CollectibleTitle)}"); + var collectible = collectibles.Items.Should().ContainSingle().Subject; + collectible.OwnedVersions.Should().ContainSingle(); + collectible.OwnedVersions[0].Reference.Should().Contain(AmazonFixtureCsvBuilder.CollectibleOrderId); + // re-preview after commit: every just-imported order must now be flagged, regardless of which // type it was imported as, so re-uploading a newer export later doesn't silently duplicate any of them var secondPreview = await PostFileAsync>("/api/import/amazon/preview", "file", csv, "orders.csv"); @@ -98,6 +116,8 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.MovieTitle && r.AlreadyImported); secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.TvShowTitle && r.AlreadyImported); secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.VideoGameTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.GearTitle && r.AlreadyImported); + secondPreview.Should().Contain(r => r.Title == AmazonFixtureCsvBuilder.CollectibleTitle && r.AlreadyImported); // committing the exact same rows again (e.g. the user re-runs the import without noticing the // "already imported" badge) must not duplicate anything - this is the bug reported in practice @@ -108,12 +128,20 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl secondCommitResult.MoviesSkipped.Should().Be(1); secondCommitResult.TvShowsSkipped.Should().Be(1); secondCommitResult.VideoGamesSkipped.Should().Be(1); + secondCommitResult.GearSkipped.Should().Be(1); + secondCommitResult.CollectiblesSkipped.Should().Be(1); var booksAfterReimport = await GetAsync>($"/api/books?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.BookTitle)}"); booksAfterReimport.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); var videoGamesAfterReimport = await GetAsync>($"/api/video-games?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.VideoGameTitle)}"); videoGamesAfterReimport.Items.Should().ContainSingle().Which.Platforms.Should().ContainSingle(); + + var gearAfterReimport = await GetAsync>($"/api/gear?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.GearTitle)}"); + gearAfterReimport.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); + + var collectiblesAfterReimport = await GetAsync>($"/api/collectibles?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.CollectibleTitle)}"); + collectiblesAfterReimport.Items.Should().ContainSingle().Which.OwnedVersions.Should().ContainSingle(); } finally { @@ -140,6 +168,18 @@ public async Task PreviewThenCommit_CreatesOneItemPerMediaType_AndFlagsThemAllAl { await DeleteAsync($"/api/video-games/{videoGame.Id}"); } + + var gearList = await GetAsync>($"/api/gear?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.GearTitle)}"); + foreach (var gear in gearList.Items.Where(g => g.Id is not null)) + { + await DeleteAsync($"/api/gear/{gear.Id}"); + } + + var collectibles = await GetAsync>($"/api/collectibles?search={Uri.EscapeDataString(AmazonFixtureCsvBuilder.CollectibleTitle)}"); + foreach (var collectible in collectibles.Items.Where(c => c.Id is not null)) + { + await DeleteAsync($"/api/collectibles/{collectible.Id}"); + } } } From ebf5061e9d122eb539be1e9f13ed7f217bcd8467 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 01:31:45 +0200 Subject: [PATCH 18/35] Add reference unlink feature for admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (5 domains: TvShow, Movie, Book, VideoGame, Album): - Added DeleteAsync(string id) to each IReferenceRepository + Mongo implementation — the shared reference collections had no delete path before. - Added UnlinkReferenceAsync(model) to ReferenceEnrichmentService's partials — clears the tenant's ReferenceId and unconditionally deletes the shared reference document. - Added POST /api//{id}/unlink-reference to each controller, gated by a method-level [Authorize(Policy = "AdminOnly")] on top of the controller's own policy. Frontend: - Added UnlinkReferenceAsync to each ApiClient. - Added an admin-only trash-icon button next to the ↻ refresh icon on all five detail pages, behind a ConfirmModal (since it's destructive/irreversible). On confirm, it unlinks, reloads the item, and shows an inline toast — InlineReferenceLinker reappears automatically since it's already conditioned on an empty ReferenceId, so the admin can immediately search and pick the correct match. Tests: new UnlinkReferenceResourceTest.cs (5 integration tests, verifying the link is cleared and the reference document is actually deleted) and UnlinkReferenceAuthorizationTest.cs (reflection guard confirming the AdminOnly policy can't be silently dropped, since the shared Firebase test account is itself an admin and can't exercise a 403 over HTTP). --- .../Inventory/Clients/AlbumApiClient.cs | 11 + .../Inventory/Clients/BookApiClient.cs | 11 + .../Inventory/Clients/MovieApiClient.cs | 11 + .../Inventory/Clients/TvShowApiClient.cs | 11 + .../Inventory/Clients/VideoGameApiClient.cs | 11 + .../Inventory/Pages/AlbumDetail.razor | 38 ++++ .../Inventory/Pages/BookDetail.razor | 38 ++++ .../Inventory/Pages/MovieDetail.razor | 38 ++++ .../Inventory/Pages/TvShowDetail.razor | 38 ++++ .../Inventory/Pages/VideoGameDetail.razor | 38 ++++ .../Repositories/IAlbumReferenceRepository.cs | 6 + .../Repositories/IBookReferenceRepository.cs | 6 + .../Repositories/IMovieReferenceRepository.cs | 6 + .../ITvShowReferenceRepository.cs | 6 + .../IVideoGameReferenceRepository.cs | 6 + .../Repositories/AlbumReferenceRepository.cs | 5 + .../Repositories/BookReferenceRepository.cs | 5 + .../Repositories/MovieReferenceRepository.cs | 5 + .../Repositories/TvShowReferenceRepository.cs | 5 + .../VideoGameReferenceRepository.cs | 5 + src/WebApi/Controllers/AlbumController.cs | 17 ++ src/WebApi/Controllers/BookController.cs | 17 ++ src/WebApi/Controllers/MovieController.cs | 17 ++ src/WebApi/Controllers/TvShowController.cs | 22 ++ src/WebApi/Controllers/VideoGameController.cs | 17 ++ .../ReferenceEnrichmentService.Albums.cs | 18 ++ .../ReferenceEnrichmentService.Books.cs | 18 ++ ...renceEnrichmentService.TvShowsAndMovies.cs | 37 ++++ .../ReferenceEnrichmentService.VideoGames.cs | 18 ++ .../Resources/UnlinkReferenceResourceTest.cs | 197 ++++++++++++++++++ .../UnlinkReferenceAuthorizationTest.cs | 38 ++++ 31 files changed, 716 insertions(+) create mode 100644 test/WebApi.IntegrationTests/Resources/UnlinkReferenceResourceTest.cs create mode 100644 test/WebApi.UnitTests/Controllers/UnlinkReferenceAuthorizationTest.cs diff --git a/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs index c86c5b12..977bb18b 100644 --- a/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs @@ -13,4 +13,15 @@ public async Task RefreshReferenceAsync(string id) response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync())!; } + + /// + /// Admin-only: unlinks and permanently deletes the shared reference document + /// (POST api/albums/{id}/unlink-reference on WebApi). + /// + public async Task UnlinkReferenceAsync(string id) + { + var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } } diff --git a/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs index 6d9a0b45..9feb451c 100644 --- a/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs @@ -13,4 +13,15 @@ public async Task RefreshReferenceAsync(string id) response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync())!; } + + /// + /// Admin-only: unlinks and permanently deletes the shared reference document + /// (POST api/books/{id}/unlink-reference on WebApi). + /// + public async Task UnlinkReferenceAsync(string id) + { + var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } } diff --git a/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs index 66c9540f..1cfa0983 100644 --- a/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs @@ -18,4 +18,15 @@ public async Task RefreshReferenceAsync(string id) response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync())!; } + + /// + /// Admin-only: unlinks and permanently deletes the shared reference document + /// (POST api/movies/{id}/unlink-reference on WebApi). + /// + public async Task UnlinkReferenceAsync(string id) + { + var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } } diff --git a/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs index 6458d356..8bd2ad4e 100644 --- a/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs @@ -18,4 +18,15 @@ public async Task RefreshReferenceAsync(string id) response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync())!; } + + /// + /// Admin-only: unlinks and permanently deletes the shared reference document + /// (POST api/tv-shows/{id}/unlink-reference on WebApi). + /// + public async Task UnlinkReferenceAsync(string id) + { + var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } } diff --git a/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs index d5e64d3b..45df7e83 100644 --- a/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs @@ -13,4 +13,15 @@ public async Task RefreshReferenceAsync(string id) response.EnsureSuccessStatusCode(); return (await response.Content.ReadFromJsonAsync())!; } + + /// + /// Admin-only: unlinks and permanently deletes the shared reference document + /// (POST api/video-games/{id}/unlink-reference on WebApi). + /// + public async Task UnlinkReferenceAsync(string id) + { + var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } } diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor index c5f76947..08e524a0 100644 --- a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor @@ -31,6 +31,17 @@ else title="@(string.IsNullOrEmpty(Album.ReferenceId) ? "Check for reference match using the title and year below." : $"Linked to \"{Reference?.Title}\". Not right? Edit the title or year below, then check again.")"> + @if (!string.IsNullOrEmpty(Album.ReferenceId)) + { + + + + + + } @if (_refreshMessage is not null) { @_refreshMessage @@ -49,6 +60,12 @@ else } + +
@{ var coverUrl = !string.IsNullOrEmpty(Album.CustomImageUrl) ? Album.CustomImageUrl : Reference?.ImageUrl; }
@@ -167,6 +184,7 @@ else private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; private object? _refreshMessageToken; + private bool _pendingUnlink; /// Reuses CastGrid's "photo + name" card for the single credited artist, instead of /// introducing a one-off component for what's visually the same layout as a TV/movie cast member. @@ -306,6 +324,26 @@ else ScheduleRefreshMessageClear(); } + private async Task UnlinkReferenceAsync() + { + _pendingUnlink = false; + if (Album?.Id is null) return; + + try + { + await AlbumApi.UnlinkReferenceAsync(Album.Id); + await LoadAsync(); + (_refreshMessage, _refreshMessageStyle) = ("Unlinked and reference removed.", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + + ScheduleRefreshMessageClear(); + } + /// /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - /// a token guards against an in-flight delay from a previous click clearing a newer message. diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index d915ccbf..cc5b18b3 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -31,6 +31,17 @@ else title="@(string.IsNullOrEmpty(Book.ReferenceId) ? "Check for reference match using the title and year below." : $"Linked to \"{Reference?.Title}\". Not right? Edit the title or year below, then check again.")"> + @if (!string.IsNullOrEmpty(Book.ReferenceId)) + { + + + + + + } @if (_refreshMessage is not null) { @_refreshMessage @@ -50,6 +61,12 @@ else } + +
@(Book.FirstReadAt is not null ? "✓ Read" : "Mark as read") @@ -153,6 +170,7 @@ else private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; private object? _refreshMessageToken; + private bool _pendingUnlink; protected override async Task OnParametersSetAsync() { @@ -303,6 +321,26 @@ else ScheduleRefreshMessageClear(); } + private async Task UnlinkReferenceAsync() + { + _pendingUnlink = false; + if (Book?.Id is null) return; + + try + { + await BookApi.UnlinkReferenceAsync(Book.Id); + await LoadAsync(); + (_refreshMessage, _refreshMessageStyle) = ("Unlinked and reference removed.", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + + ScheduleRefreshMessageClear(); + } + /// /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - /// a token guards against an in-flight delay from a previous click clearing a newer message. diff --git a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor index f391c731..224d5326 100644 --- a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor @@ -31,6 +31,17 @@ else title="@(string.IsNullOrEmpty(Movie.ReferenceId) ? "Check for reference match using the title and year below." : $"Linked to \"{Reference?.Title}\". Not right? Edit the title or year below, then check again.")"> + @if (!string.IsNullOrEmpty(Movie.ReferenceId)) + { + + + + + + } @if (_refreshMessage is not null) { @_refreshMessage @@ -51,6 +62,12 @@ else } + +
@(Movie.FirstSeenAt is not null ? "✓ Watched" : "Mark as watched") @@ -136,6 +153,7 @@ else private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; private object? _refreshMessageToken; + private bool _pendingUnlink; protected override async Task OnParametersSetAsync() { @@ -249,6 +267,26 @@ else ScheduleRefreshMessageClear(); } + private async Task UnlinkReferenceAsync() + { + _pendingUnlink = false; + if (Movie?.Id is null) return; + + try + { + await MovieApi.UnlinkReferenceAsync(Movie.Id); + await LoadAsync(); + (_refreshMessage, _refreshMessageStyle) = ("Unlinked and reference removed.", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + + ScheduleRefreshMessageClear(); + } + /// /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - /// a token guards against an in-flight delay from a previous click clearing a newer message. diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor index f501fab0..009b75a0 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -31,6 +31,17 @@ else title="@(string.IsNullOrEmpty(_show.ReferenceId) ? "Check for reference match using the title and year below." : $"Linked to \"{_reference?.Title}\". Not right? Edit the title or year below, then check again.")"> + @if (!string.IsNullOrEmpty(_show.ReferenceId)) + { + + + + + + } @if (_refreshMessage is not null) { @_refreshMessage @@ -58,6 +69,12 @@ else } + +
@if (!string.IsNullOrEmpty(_reference?.ImageUrl)) @@ -236,6 +253,7 @@ else private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; private object? _refreshMessageToken; + private bool _pendingUnlink; private int _newSeason = 1; private int _newEpisode = 1; @@ -372,6 +390,26 @@ else ScheduleRefreshMessageClear(); } + private async Task UnlinkReferenceAsync() + { + _pendingUnlink = false; + if (_show?.Id is null) return; + + try + { + await TvShowApi.UnlinkReferenceAsync(_show.Id); + await LoadAsync(); + (_refreshMessage, _refreshMessageStyle) = ("Unlinked and reference removed.", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + + ScheduleRefreshMessageClear(); + } + /// /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - /// a token guards against an in-flight delay from a previous click clearing a newer message. diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 94500257..3199cbb0 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -31,6 +31,17 @@ else title="@(string.IsNullOrEmpty(Game.ReferenceId) ? "Check for reference match using the title and year below." : $"Linked to \"{Reference?.Title}\". Not right? Edit the title or year below, then check again.")"> + @if (!string.IsNullOrEmpty(Game.ReferenceId)) + { + + + + + + } @if (_refreshMessage is not null) { @_refreshMessage @@ -50,6 +61,12 @@ else } + + var coverUrl = !string.IsNullOrEmpty(Game.CustomImageUrl) ? Game.CustomImageUrl : Reference?.ImageUrl; @if (!string.IsNullOrEmpty(coverUrl)) { @@ -226,6 +243,7 @@ else private object? _refreshMessageToken; private VideoGamePlatformDto? _draftPlatform; private VideoGamePlatformDto? _pendingRemovePlatform; + private bool _pendingUnlink; protected override async Task OnParametersSetAsync() { @@ -447,6 +465,26 @@ else ScheduleRefreshMessageClear(); } + private async Task UnlinkReferenceAsync() + { + _pendingUnlink = false; + if (Game?.Id is null) return; + + try + { + await VideoGameApi.UnlinkReferenceAsync(Game.Id); + await LoadAsync(); + (_refreshMessage, _refreshMessageStyle) = ("Unlinked and reference removed.", "success"); + } + catch (Exception ex) + { + _refreshMessage = $"Error: {ex.Message}"; + _refreshMessageStyle = "danger"; + } + + ScheduleRefreshMessageClear(); + } + /// /// The toast is a small, auto-dismissing pill next to the refresh icon, not a permanent line of text - /// a token guards against an in-flight delay from a previous click clearing a newer message. diff --git a/src/Domain/Repositories/IAlbumReferenceRepository.cs b/src/Domain/Repositories/IAlbumReferenceRepository.cs index 449480a0..d2f81a3a 100644 --- a/src/Domain/Repositories/IAlbumReferenceRepository.cs +++ b/src/Domain/Repositories/IAlbumReferenceRepository.cs @@ -45,4 +45,10 @@ public interface IAlbumReferenceRepository /// full unpaged read is fine. /// Task> FindAllAsync(); + + /// + /// Permanently removes a reference document - backs the admin "unlink" action, which deletes the + /// shared document outright rather than merely detaching one tenant's link. + /// + Task DeleteAsync(string id); } diff --git a/src/Domain/Repositories/IBookReferenceRepository.cs b/src/Domain/Repositories/IBookReferenceRepository.cs index d19aea9d..919af338 100644 --- a/src/Domain/Repositories/IBookReferenceRepository.cs +++ b/src/Domain/Repositories/IBookReferenceRepository.cs @@ -45,4 +45,10 @@ public interface IBookReferenceRepository /// full unpaged read is fine. /// Task> FindAllAsync(); + + /// + /// Permanently removes a reference document - backs the admin "unlink" action, which deletes the + /// shared document outright rather than merely detaching one tenant's link. + /// + Task DeleteAsync(string id); } diff --git a/src/Domain/Repositories/IMovieReferenceRepository.cs b/src/Domain/Repositories/IMovieReferenceRepository.cs index 56c30465..1f6d41ed 100644 --- a/src/Domain/Repositories/IMovieReferenceRepository.cs +++ b/src/Domain/Repositories/IMovieReferenceRepository.cs @@ -39,4 +39,10 @@ public interface IMovieReferenceRepository /// full unpaged read is fine. ///
Task> FindAllAsync(); + + /// + /// Permanently removes a reference document - backs the admin "unlink" action, which deletes the + /// shared document outright rather than merely detaching one tenant's link. + /// + Task DeleteAsync(string id); } diff --git a/src/Domain/Repositories/ITvShowReferenceRepository.cs b/src/Domain/Repositories/ITvShowReferenceRepository.cs index 48d1cf30..2b1cd6e8 100644 --- a/src/Domain/Repositories/ITvShowReferenceRepository.cs +++ b/src/Domain/Repositories/ITvShowReferenceRepository.cs @@ -40,4 +40,10 @@ public interface ITvShowReferenceRepository /// full unpaged read is fine. ///
Task> FindAllAsync(); + + /// + /// Permanently removes a reference document - backs the admin "unlink" action, which deletes the + /// shared document outright rather than merely detaching one tenant's link. + /// + Task DeleteAsync(string id); } diff --git a/src/Domain/Repositories/IVideoGameReferenceRepository.cs b/src/Domain/Repositories/IVideoGameReferenceRepository.cs index 82d7479f..a772cd9a 100644 --- a/src/Domain/Repositories/IVideoGameReferenceRepository.cs +++ b/src/Domain/Repositories/IVideoGameReferenceRepository.cs @@ -38,4 +38,10 @@ public interface IVideoGameReferenceRepository /// full unpaged read is fine. ///
Task> FindAllAsync(); + + /// + /// Permanently removes a reference document - backs the admin "unlink" action, which deletes the + /// shared document outright rather than merely detaching one tenant's link. + /// + Task DeleteAsync(string id); } diff --git a/src/Infrastructure.MongoDb/Repositories/AlbumReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/AlbumReferenceRepository.cs index a50fec1b..44b80e2d 100644 --- a/src/Infrastructure.MongoDb/Repositories/AlbumReferenceRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/AlbumReferenceRepository.cs @@ -88,4 +88,9 @@ public async Task UpsertAsync(AlbumReferenceModel model) return mapper.ToModel(entity); } + + public async Task DeleteAsync(string id) + { + await Collection.DeleteOneAsync(x => x.Id == id); + } } diff --git a/src/Infrastructure.MongoDb/Repositories/BookReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/BookReferenceRepository.cs index 82d34690..51502e62 100644 --- a/src/Infrastructure.MongoDb/Repositories/BookReferenceRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/BookReferenceRepository.cs @@ -90,4 +90,9 @@ public async Task UpsertAsync(BookReferenceModel model) return mapper.ToModel(entity); } + + public async Task DeleteAsync(string id) + { + await Collection.DeleteOneAsync(x => x.Id == id); + } } diff --git a/src/Infrastructure.MongoDb/Repositories/MovieReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/MovieReferenceRepository.cs index be1f86b9..6bb3e274 100644 --- a/src/Infrastructure.MongoDb/Repositories/MovieReferenceRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/MovieReferenceRepository.cs @@ -91,4 +91,9 @@ public async Task UpsertAsync(MovieReferenceModel model) return mapper.ToModel(entity); } + + public async Task DeleteAsync(string id) + { + await Collection.DeleteOneAsync(x => x.Id == id); + } } diff --git a/src/Infrastructure.MongoDb/Repositories/TvShowReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/TvShowReferenceRepository.cs index 54e70882..ac058e26 100644 --- a/src/Infrastructure.MongoDb/Repositories/TvShowReferenceRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/TvShowReferenceRepository.cs @@ -86,4 +86,9 @@ public async Task UpsertAsync(TvShowReferenceModel model) return mapper.ToModel(entity); } + + public async Task DeleteAsync(string id) + { + await Collection.DeleteOneAsync(x => x.Id == id); + } } diff --git a/src/Infrastructure.MongoDb/Repositories/VideoGameReferenceRepository.cs b/src/Infrastructure.MongoDb/Repositories/VideoGameReferenceRepository.cs index af6278bd..0d71b2a9 100644 --- a/src/Infrastructure.MongoDb/Repositories/VideoGameReferenceRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/VideoGameReferenceRepository.cs @@ -80,4 +80,9 @@ public async Task UpsertAsync(VideoGameReferenceModel m return mapper.ToModel(entity); } + + public async Task DeleteAsync(string id) + { + await Collection.DeleteOneAsync(x => x.Id == id); + } } diff --git a/src/WebApi/Controllers/AlbumController.cs b/src/WebApi/Controllers/AlbumController.cs index 8769b30a..2c8722f2 100644 --- a/src/WebApi/Controllers/AlbumController.cs +++ b/src/WebApi/Controllers/AlbumController.cs @@ -73,4 +73,21 @@ public async Task> RefreshReference(string id) model = await enrichmentService.TryLinkExistingAlbumReferenceAsync(model); return Ok(Mapper.ToDto(model)); } + + /// + /// Admin-only: clears this album's reference link and permanently deletes the shared reference + /// document - see . + /// + [HttpPost("{id}/unlink-reference")] + [Authorize(Policy = "AdminOnly")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> UnlinkReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.UnlinkAlbumReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } } diff --git a/src/WebApi/Controllers/BookController.cs b/src/WebApi/Controllers/BookController.cs index ee55bd58..b978c64e 100644 --- a/src/WebApi/Controllers/BookController.cs +++ b/src/WebApi/Controllers/BookController.cs @@ -74,4 +74,21 @@ public async Task> RefreshReference(string id) model = await enrichmentService.TryLinkExistingBookReferenceAsync(model); return Ok(Mapper.ToDto(model)); } + + /// + /// Admin-only: clears this book's reference link and permanently deletes the shared reference + /// document - see . + /// + [HttpPost("{id}/unlink-reference")] + [Authorize(Policy = "AdminOnly")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> UnlinkReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.UnlinkBookReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } } diff --git a/src/WebApi/Controllers/MovieController.cs b/src/WebApi/Controllers/MovieController.cs index 0de482d1..733ffe91 100644 --- a/src/WebApi/Controllers/MovieController.cs +++ b/src/WebApi/Controllers/MovieController.cs @@ -67,4 +67,21 @@ public async Task> RefreshReference(string id) model = await enrichmentService.TryLinkExistingMovieReferenceAsync(model); return Ok(Mapper.ToDto(model)); } + + /// + /// Admin-only: clears this movie's reference link and permanently deletes the shared reference + /// document - see . + /// + [HttpPost("{id}/unlink-reference")] + [Authorize(Policy = "AdminOnly")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> UnlinkReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.UnlinkMovieReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } } diff --git a/src/WebApi/Controllers/TvShowController.cs b/src/WebApi/Controllers/TvShowController.cs index bf5de1cc..b7f50e3a 100644 --- a/src/WebApi/Controllers/TvShowController.cs +++ b/src/WebApi/Controllers/TvShowController.cs @@ -69,4 +69,26 @@ public async Task> RefreshReference(string id) model = await enrichmentService.TryLinkExistingTvShowReferenceAsync(model); return Ok(Mapper.ToDto(model)); } + + /// + /// Admin-only: clears this show's reference link and permanently deletes the shared reference document + /// itself, rather than merely detaching this one tenant's link - the admin has determined the match was + /// wrong, so the document behind it is bad data. Unlike (open to any + /// owner, harmless/idempotent), this mutates shared data other tenants could theoretically point at, so + /// it's gated to admins via a method-level policy on top of the controller's own plain [Authorize]. + /// Clearing ReferenceId is also what makes the Blazor detail page's InlineReferenceLinker + /// search card reappear, letting the admin immediately pick the correct match. + /// + [HttpPost("{id}/unlink-reference")] + [Authorize(Policy = "AdminOnly")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> UnlinkReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.UnlinkTvShowReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } } diff --git a/src/WebApi/Controllers/VideoGameController.cs b/src/WebApi/Controllers/VideoGameController.cs index c9f7f4d0..e8e9485c 100644 --- a/src/WebApi/Controllers/VideoGameController.cs +++ b/src/WebApi/Controllers/VideoGameController.cs @@ -72,4 +72,21 @@ public async Task> RefreshReference(string id) model = await enrichmentService.TryLinkExistingVideoGameReferenceAsync(model); return Ok(Mapper.ToDto(model)); } + + /// + /// Admin-only: clears this game's reference link and permanently deletes the shared reference + /// document - see . + /// + [HttpPost("{id}/unlink-reference")] + [Authorize(Policy = "AdminOnly")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public async Task> UnlinkReference(string id) + { + var model = await dataRepository.FindOneAsync(id, this.GetUserId()); + if (model is null) return NotFound(); + + model = await enrichmentService.UnlinkVideoGameReferenceAsync(model); + return Ok(Mapper.ToDto(model)); + } } diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs index 5d971e3f..83a74490 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs @@ -54,6 +54,24 @@ public async Task TryLinkExistingAlbumReferenceAsync(AlbumModel mode return model; } + /// + /// Admin-triggered "unlink" for albums - see for the full + /// rationale (clears the tenant's link and permanently deletes the shared reference document, rather + /// than only detaching this one item). + /// + public async Task UnlinkAlbumReferenceAsync(AlbumModel model) + { + var referenceId = model.ReferenceId; + model.ReferenceId = string.Empty; + await albumRepository.UpdateAsync(model.Id!, model, model.OwnerId); + if (!string.IsNullOrEmpty(referenceId)) + { + await albumReferenceRepository.DeleteAsync(referenceId); + } + + return model; + } + /// /// Best-effort automatic match for albums - see . Passing /// narrows the Discogs search considerably - without it, a common album diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs index a8203b06..6d402d15 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Books.cs @@ -57,6 +57,24 @@ public async Task TryLinkExistingBookReferenceAsync(BookModel model) return model; } + /// + /// Admin-triggered "unlink" for books - see for the full + /// rationale (clears the tenant's link and permanently deletes the shared reference document, rather + /// than only detaching this one item). + /// + public async Task UnlinkBookReferenceAsync(BookModel model) + { + var referenceId = model.ReferenceId; + model.ReferenceId = string.Empty; + await bookRepository.UpdateAsync(model.Id!, model, model.OwnerId); + if (!string.IsNullOrEmpty(referenceId)) + { + await bookReferenceRepository.DeleteAsync(referenceId); + } + + return model; + } + /// /// Best-effort automatic match for books - see . Always searches /// the deployment's *default* provider ( with a null diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs index 967794ad..0150b4be 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.TvShowsAndMovies.cs @@ -110,6 +110,43 @@ public async Task TryLinkExistingMovieReferenceAsync(MovieModel mode return model; } + /// + /// Admin-triggered "unlink" - clears this tenant's own and, unlike + /// the implicit clear-on-no-match branch inside , + /// permanently deletes the shared reference document itself (the whole point: the admin has determined + /// this specific match was wrong, so the document behind it is bad data, not just wrong for this tenant). + /// Deliberately doesn't check whether any other tenant document still points at the same reference id - + /// an accepted, rare edge case, not worth the complexity of guarding against. + /// + public async Task UnlinkTvShowReferenceAsync(TvShowModel model) + { + var referenceId = model.ReferenceId; + model.ReferenceId = string.Empty; + await tvShowRepository.UpdateAsync(model.Id!, model, model.OwnerId); + if (!string.IsNullOrEmpty(referenceId)) + { + await tvShowReferenceRepository.DeleteAsync(referenceId); + } + + return model; + } + + /// + /// Movie equivalent of . + /// + public async Task UnlinkMovieReferenceAsync(MovieModel model) + { + var referenceId = model.ReferenceId; + model.ReferenceId = string.Empty; + await movieRepository.UpdateAsync(model.Id!, model, model.OwnerId); + if (!string.IsNullOrEmpty(referenceId)) + { + await movieReferenceRepository.DeleteAsync(referenceId); + } + + return model; + } + /// /// Best-effort automatic match: does nothing if the search returns zero or more than one candidate, /// leaving the show unresolved for the admin queue instead of guessing. diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs index 812e4b56..eea65aff 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.VideoGames.cs @@ -49,6 +49,24 @@ public async Task TryLinkExistingVideoGameReferenceAsync(VideoGa return model; } + /// + /// Admin-triggered "unlink" for video games - see for the full + /// rationale (clears the tenant's link and permanently deletes the shared reference document, rather + /// than only detaching this one item). + /// + public async Task UnlinkVideoGameReferenceAsync(VideoGameModel model) + { + var referenceId = model.ReferenceId; + model.ReferenceId = string.Empty; + await videoGameRepository.UpdateAsync(model.Id!, model, model.OwnerId); + if (!string.IsNullOrEmpty(referenceId)) + { + await videoGameReferenceRepository.DeleteAsync(referenceId); + } + + return model; + } + /// /// Best-effort automatic match for video games - see . /// diff --git a/test/WebApi.IntegrationTests/Resources/UnlinkReferenceResourceTest.cs b/test/WebApi.IntegrationTests/Resources/UnlinkReferenceResourceTest.cs new file mode 100644 index 00000000..7da16b4d --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/UnlinkReferenceResourceTest.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Exercises POST /api/{tv-shows,movies,books,video-games,albums}/{id}/unlink-reference - the admin +/// action that clears a tenant's own ReferenceId and permanently deletes the shared reference +/// document behind a wrong match. The integration suite's shared Firebase test user is an admin (see +/// FreeTierTest's own doc comment), so already +/// satisfies the endpoint's AdminOnly policy - the policy attribute itself is covered by a +/// reflection unit test instead (an HTTP 403 test would need a second, non-admin Firebase account this +/// suite doesn't have configured). +/// +public class UnlinkReferenceResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + [Fact] + public async Task UnlinkReference_ClearsTvShowLink_AndDeletesTheReferenceDocument() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Unlink Reference Test Show {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new TvShowReferenceModel + { + Title = "Canonical Title", + TitleNormalized = "canonical title", + Year = year, + ExternalIds = new Dictionary { ["tmdb"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] + }); + + await Authenticate(); + var created = await PostAsync("/api/tv-shows", new TvShowDto { Title = title, Year = year }); + await PostAsync($"/api/tv-shows/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + try + { + var unlinked = await PostAsync($"/api/tv-shows/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); + } + finally + { + await DeleteAsync($"/api/tv-shows/{created.Id}"); + } + } + + [Fact] + public async Task UnlinkReference_ClearsMovieLink_AndDeletesTheReferenceDocument() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Unlink Reference Test Movie {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new MovieReferenceModel + { + Title = "Canonical Movie Title", + TitleNormalized = "canonical movie title", + Year = year, + ExternalIds = new Dictionary { ["tmdb"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] + }); + + await Authenticate(); + var created = await PostAsync("/api/movies", new MovieDto { Title = title, Year = year }); + await PostAsync($"/api/movies/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + try + { + var unlinked = await PostAsync($"/api/movies/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); + } + finally + { + await DeleteAsync($"/api/movies/{created.Id}"); + } + } + + [Fact] + public async Task UnlinkReference_ClearsBookLink_AndDeletesTheReferenceDocument() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Unlink Reference Test Book {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new BookReferenceModel + { + Title = "Canonical Book Title", + TitleNormalized = "canonical book title", + Year = year, + ExternalIds = new Dictionary { ["openlibrary"] = "OL1W" }, + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year, Creator = TitleNormalizer.Normalize("Some Author") }] + }); + + await Authenticate(); + var created = await PostAsync("/api/books", new BookDto { Title = title, Author = "Some Author", Year = year }); + await PostAsync($"/api/books/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + try + { + var unlinked = await PostAsync($"/api/books/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); + } + finally + { + await DeleteAsync($"/api/books/{created.Id}"); + } + } + + [Fact] + public async Task UnlinkReference_ClearsVideoGameLink_AndDeletesTheReferenceDocument() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Unlink Reference Test Game {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new VideoGameReferenceModel + { + Title = "Canonical Game Title", + TitleNormalized = "canonical game title", + Year = year, + ExternalIds = new Dictionary { ["rawg"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year }] + }); + + await Authenticate(); + var created = await PostAsync("/api/video-games", new VideoGameDto { Title = title, Year = year }); + await PostAsync($"/api/video-games/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + try + { + var unlinked = await PostAsync($"/api/video-games/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); + } + finally + { + await DeleteAsync($"/api/video-games/{created.Id}"); + } + } + + [Fact] + public async Task UnlinkReference_ClearsAlbumLink_AndDeletesTheReferenceDocument() + { + using var scope = Factory.Services.CreateScope(); + var referenceRepository = scope.ServiceProvider.GetRequiredService(); + var title = $"Unlink Reference Test Album {Guid.NewGuid()}"; + const int year = 2019; + + var reference = await referenceRepository.UpsertAsync(new AlbumReferenceModel + { + Title = "Canonical Album Title", + TitleNormalized = "canonical album title", + Year = year, + ExternalIds = new Dictionary { ["discogs"] = "1" }, + MatchedAliases = [new ReferenceMatchModel { Title = TitleNormalizer.Normalize(title), Year = year, Creator = TitleNormalizer.Normalize("Some Artist") }] + }); + + await Authenticate(); + var created = await PostAsync("/api/albums", new AlbumDto { Title = title, Artist = "Some Artist", Year = year }); + await PostAsync($"/api/albums/{created.Id}/refresh-reference", null, HttpStatusCode.OK); + + try + { + var unlinked = await PostAsync($"/api/albums/{created.Id}/unlink-reference", null, HttpStatusCode.OK); + + unlinked!.ReferenceId.Should().BeNullOrEmpty(); + (await referenceRepository.FindByIdAsync(reference.Id!)).Should().BeNull(); + } + finally + { + await DeleteAsync($"/api/albums/{created.Id}"); + } + } +} diff --git a/test/WebApi.UnitTests/Controllers/UnlinkReferenceAuthorizationTest.cs b/test/WebApi.UnitTests/Controllers/UnlinkReferenceAuthorizationTest.cs new file mode 100644 index 00000000..8df92675 --- /dev/null +++ b/test/WebApi.UnitTests/Controllers/UnlinkReferenceAuthorizationTest.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using System.Reflection; +using AwesomeAssertions; +using Keeptrack.WebApi.Controllers; +using Microsoft.AspNetCore.Authorization; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Controllers; + +/// +/// The "unlink-reference" action on each reference-linked controller deletes shared data (the reference +/// document itself), unlike the harmless/idempotent "refresh-reference" action next to it - it must +/// carry its own method-level AdminOnly policy on top of whatever the controller class itself +/// requires. A reflection guard here (same shape as FreeTierTest.Controller_CarriesTheExpectedAuthorizationPolicy, +/// just at the method level instead of the class level) means removing the attribute is a failing test, +/// not a silent privilege-escalation bug - the integration suite can't exercise a 403 here directly since +/// its one Firebase test user is an admin (see FreeTierTest's own doc comment). +/// +[Trait("Category", "UnitTests")] +public class UnlinkReferenceAuthorizationTest +{ + [Theory] + [InlineData(typeof(TvShowController))] + [InlineData(typeof(MovieController))] + [InlineData(typeof(BookController))] + [InlineData(typeof(VideoGameController))] + [InlineData(typeof(AlbumController))] + public void UnlinkReference_CarriesTheAdminOnlyPolicy(Type controllerType) + { + var method = controllerType.GetMethod("UnlinkReference", BindingFlags.Public | BindingFlags.Instance) + ?? throw new InvalidOperationException($"{controllerType.Name} has no UnlinkReference method."); + + var attribute = method.GetCustomAttributes(inherit: false).Single(); + + attribute.Policy.Should().Be("AdminOnly"); + } +} From cf94ca0c07a9cb8761745ddf670cbb8fd61e77be Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 22:46:40 +0200 Subject: [PATCH 19/35] Add link to book ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New user-preferences pattern: UserPreferencesModel/Repository/StorageMapper (Domain + Infrastructure.MongoDb), UserPreferencesDto/DtoMapper/UserPreferencesController (WebApi, GET/PUT /api/user-preferences), a unique owner_id index, and a UserPreferencesApiClient on the Blazor side — designed so a future toggle is just one more named boolean, no new plumbing. - /account/manage: now has a "Preferences" section with a "Show chasse-aux-livres.fr link on book pages" toggle that persists immediately. - BookDetail.razor: the ISBN field is now an input-group; when an ISBN is set and the preference is on, a ↗ button opens https://www.chasse-aux-livres.fr/ in a new tab. --- scripts/mongodb-create-index.js | 5 ++ .../Components/Account/Pages/Manage.razor | 31 +++++++++++- .../Account/UserPreferencesApiClient.cs | 15 ++++++ .../Inventory/Pages/BookDetail.razor | 17 ++++++- .../Components/Shared/ExternalLinkIcon.razor | 14 ++++++ ...frastructureServiceCollectionExtensions.cs | 2 + src/Domain/Models/UserPreferencesModel.cs | 22 ++++++++ .../IUserPreferencesRepository.cs | 23 +++++++++ .../Entities/UserPreferences.cs | 18 +++++++ .../Mappers/UserPreferencesStorageMapper.cs | 16 ++++++ .../Repositories/UserPreferencesRepository.cs | 28 +++++++++++ .../Dto/UserPreferencesDto.cs | 12 +++++ .../Controllers/UserPreferencesController.cs | 47 +++++++++++++++++ ...frastructureServiceCollectionExtensions.cs | 2 + .../Mappers/UserPreferencesDtoMapper.cs | 22 ++++++++ src/WebApi/Program.cs | 1 + .../Resources/UserPreferencesResourceTest.cs | 50 +++++++++++++++++++ 17 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 src/BlazorApp/Components/Account/UserPreferencesApiClient.cs create mode 100644 src/BlazorApp/Components/Shared/ExternalLinkIcon.razor create mode 100644 src/Domain/Models/UserPreferencesModel.cs create mode 100644 src/Domain/Repositories/IUserPreferencesRepository.cs create mode 100644 src/Infrastructure.MongoDb/Entities/UserPreferences.cs create mode 100644 src/Infrastructure.MongoDb/Mappers/UserPreferencesStorageMapper.cs create mode 100644 src/Infrastructure.MongoDb/Repositories/UserPreferencesRepository.cs create mode 100644 src/WebApi.Contracts/Dto/UserPreferencesDto.cs create mode 100644 src/WebApi/Controllers/UserPreferencesController.cs create mode 100644 src/WebApi/Mappers/UserPreferencesDtoMapper.cs create mode 100644 test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index bbc66b9b..da7ceaa1 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -270,3 +270,8 @@ ensureIndex( { "external_ids.discogs": 1 }, { name: "album_reference_discogs_id", unique: true, partialFilterExpression: { "external_ids.discogs": { $exists: true } } } ); + +// user_preferences: exactly one document per owner (upserted by owner_id, never listed) - the unique +// index is what actually guarantees that, the same way the application-level upsert-by-owner-id logic in +// UserPreferencesRepository is only "supposed to" prevent a second document. +ensureIndex(db.user_preferences, { owner_id: 1 }, { name: "user_preferences_owner", unique: true }); diff --git a/src/BlazorApp/Components/Account/Pages/Manage.razor b/src/BlazorApp/Components/Account/Pages/Manage.razor index fc74f1a0..8761af71 100644 --- a/src/BlazorApp/Components/Account/Pages/Manage.razor +++ b/src/BlazorApp/Components/Account/Pages/Manage.razor @@ -1,5 +1,6 @@ -@page "/account/manage" +@page "/account/manage" @attribute [Authorize] +@inject UserPreferencesApiClient PreferencesApi

Manage

@@ -10,3 +11,31 @@

+ +@if (_preferences is not null) +{ +

Preferences

+
+ + +
+} + +@code { + private UserPreferencesDto? _preferences; + + protected override async Task OnInitializedAsync() + { + _preferences = await PreferencesApi.GetAsync(); + } + + private async Task SetShowChasseAuxLivresLinkAsync(bool value) + { + if (_preferences is null) return; + _preferences.ShowChasseAuxLivresLink = value; + await PreferencesApi.UpdateAsync(_preferences); + } +} diff --git a/src/BlazorApp/Components/Account/UserPreferencesApiClient.cs b/src/BlazorApp/Components/Account/UserPreferencesApiClient.cs new file mode 100644 index 00000000..73fd5964 --- /dev/null +++ b/src/BlazorApp/Components/Account/UserPreferencesApiClient.cs @@ -0,0 +1,15 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Account; + +public sealed class UserPreferencesApiClient(HttpClient http) +{ + public async Task GetAsync() + { + var result = await http.GetFromJsonAsync("/api/user-preferences"); + return result ?? new UserPreferencesDto(); + } + + public async Task UpdateAsync(UserPreferencesDto dto) => + (await http.PutAsJsonAsync("/api/user-preferences", dto)).EnsureSuccessStatusCode(); +} diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index cc5b18b3..ef65297f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -115,7 +115,16 @@ else
- +
+ + @if (!string.IsNullOrEmpty(Book.Isbn) && ShowChasseAuxLivresLink) + { + + + + } +
@@ -148,6 +157,8 @@ else [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; + [Inject] private UserPreferencesApiClient PreferencesApi { get; set; } = null!; + private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. @@ -166,6 +177,9 @@ else [PersistentState] public BookReferenceDto? Reference { get; set; } + + [PersistentState] + public bool ShowChasseAuxLivresLink { get; set; } private bool _refreshingReference; private string? _refreshMessage; private string _refreshMessageStyle = "neutral"; @@ -196,6 +210,7 @@ else { Book = await BookApi.GetOneAsync(Id); Reference = string.IsNullOrEmpty(Book?.ReferenceId) ? null : await ReferenceDataApi.GetBookAsync(Book.ReferenceId); + ShowChasseAuxLivresLink = (await PreferencesApi.GetAsync()).ShowChasseAuxLivresLink; } private async Task SetRatingAsync(float rating) diff --git a/src/BlazorApp/Components/Shared/ExternalLinkIcon.razor b/src/BlazorApp/Components/Shared/ExternalLinkIcon.razor new file mode 100644 index 00000000..500a7def --- /dev/null +++ b/src/BlazorApp/Components/Shared/ExternalLinkIcon.razor @@ -0,0 +1,14 @@ +@* A monochrome inline SVG "open external link" glyph, same shape as TrashIcon - used for actions that + leave the app (e.g. the chasse-aux-livres.fr shortcut on BookDetail.razor). Wrap it in the action's own + button/anchor; this is just the glyph. *@ + + + +@code { + [Parameter] public int Size { get; set; } = 15; +} diff --git a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index a4756214..2ebff0f0 100644 --- a/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/BlazorApp/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -57,6 +57,8 @@ internal static void AddWebApiHttpClient(this IServiceCollection services, strin .AddHttpMessageHandler(); services.AddHttpClient(client => client.BaseAddress = webApiUri) .AddHttpMessageHandler(); + services.AddHttpClient(client => client.BaseAddress = webApiUri) + .AddHttpMessageHandler(); // deliberately NO AuthenticationTokenHandler: the shared-wishlist view is anonymous by design services.AddHttpClient(client => client.BaseAddress = webApiUri); } diff --git a/src/Domain/Models/UserPreferencesModel.cs b/src/Domain/Models/UserPreferencesModel.cs new file mode 100644 index 00000000..79ebc5ed --- /dev/null +++ b/src/Domain/Models/UserPreferencesModel.cs @@ -0,0 +1,22 @@ +using Keeptrack.Common.System; + +namespace Keeptrack.Domain.Models; + +/// +/// One document per user, holding opt-in/opt-out toggles for features that are useful but not something +/// every user wants surfaced (see ). A new feature of +/// this kind adds its own named boolean property here, the same convention every other flag in this +/// codebase already follows (e.g. ) - not a generic settings dictionary. +/// +public class UserPreferencesModel : IHasIdAndOwnerId +{ + public string? Id { get; set; } + + public required string OwnerId { get; set; } + + /// + /// Shows an "open in a new tab" link to https://www.chasse-aux-livres.fr/{isbn} next to a book's ISBN + /// field on BookDetail.razor, for users who use that site to price-check/verify French books. + /// + public bool ShowChasseAuxLivresLink { get; set; } +} diff --git a/src/Domain/Repositories/IUserPreferencesRepository.cs b/src/Domain/Repositories/IUserPreferencesRepository.cs new file mode 100644 index 00000000..adc73eea --- /dev/null +++ b/src/Domain/Repositories/IUserPreferencesRepository.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using Keeptrack.Domain.Models; + +namespace Keeptrack.Domain.Repositories; + +/// +/// A single per-user preferences document, not a full owner-scoped CRUD collection (there's exactly one +/// document per owner, never listed/paged) - a small purpose-built repository like +/// , rather than forced through . +/// +public interface IUserPreferencesRepository +{ + /// + /// The owner's preferences, or null if they've never saved any - callers should fall back to an + /// all-default instance rather than writing one on read. + /// + Task FindByOwnerIdAsync(string ownerId); + + /// + /// Creates or fully replaces the owner's preferences document. + /// + Task UpsertAsync(UserPreferencesModel model); +} diff --git a/src/Infrastructure.MongoDb/Entities/UserPreferences.cs b/src/Infrastructure.MongoDb/Entities/UserPreferences.cs new file mode 100644 index 00000000..3c2c318a --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/UserPreferences.cs @@ -0,0 +1,18 @@ +using Keeptrack.Common.System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class UserPreferences : IHasIdAndOwnerId +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + + [BsonElement("owner_id")] + public required string OwnerId { get; set; } + + [BsonElement("show_chasse_aux_livres_link")] + public bool ShowChasseAuxLivresLink { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Mappers/UserPreferencesStorageMapper.cs b/src/Infrastructure.MongoDb/Mappers/UserPreferencesStorageMapper.cs new file mode 100644 index 00000000..e5c5af2f --- /dev/null +++ b/src/Infrastructure.MongoDb/Mappers/UserPreferencesStorageMapper.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Keeptrack.Domain.Models; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.Infrastructure.MongoDb.Mappers; + +[Mapper] +public partial class UserPreferencesStorageMapper : IStorageMapper +{ + public partial UserPreferences ToEntity(UserPreferencesModel model); + + public partial UserPreferencesModel ToModel(UserPreferences entity); + + public partial List ToModels(List entities); +} diff --git a/src/Infrastructure.MongoDb/Repositories/UserPreferencesRepository.cs b/src/Infrastructure.MongoDb/Repositories/UserPreferencesRepository.cs new file mode 100644 index 00000000..12cbd2cd --- /dev/null +++ b/src/Infrastructure.MongoDb/Repositories/UserPreferencesRepository.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.Infrastructure.MongoDb.Entities; +using Keeptrack.Infrastructure.MongoDb.Mappers; +using MongoDB.Driver; + +namespace Keeptrack.Infrastructure.MongoDb.Repositories; + +public class UserPreferencesRepository(IMongoDatabase mongoDatabase, UserPreferencesStorageMapper mapper) : IUserPreferencesRepository +{ + private const string CollectionName = "user_preferences"; + + private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); + + public async Task FindByOwnerIdAsync(string ownerId) + { + var entity = await Collection.Find(x => x.OwnerId == ownerId).FirstOrDefaultAsync(); + // the usual null guard before mapping - see MongoDbRepositoryBase.FindOneAsync's identical shape + return entity is null ? null : mapper.ToModel(entity); + } + + public async Task UpsertAsync(UserPreferencesModel model) + { + var entity = mapper.ToEntity(model); + await Collection.ReplaceOneAsync(x => x.OwnerId == model.OwnerId, entity, new ReplaceOptions { IsUpsert = true }); + } +} diff --git a/src/WebApi.Contracts/Dto/UserPreferencesDto.cs b/src/WebApi.Contracts/Dto/UserPreferencesDto.cs new file mode 100644 index 00000000..79f9d0b2 --- /dev/null +++ b/src/WebApi.Contracts/Dto/UserPreferencesDto.cs @@ -0,0 +1,12 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +/// +/// The caller's own opt-in/opt-out feature toggles. Always "mine" - never referenced by id, never listed. +/// +public class UserPreferencesDto +{ + /// + /// Shows an "open in a new tab" link to chasse-aux-livres.fr next to a book's ISBN field. + /// + public bool ShowChasseAuxLivresLink { get; set; } +} diff --git a/src/WebApi/Controllers/UserPreferencesController.cs b/src/WebApi/Controllers/UserPreferencesController.cs new file mode 100644 index 00000000..8c3d66a9 --- /dev/null +++ b/src/WebApi/Controllers/UserPreferencesController.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; +using Keeptrack.Domain.Models; +using Keeptrack.Domain.Repositories; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.Mappers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Keeptrack.WebApi.Controllers; + +/// +/// The caller's own opt-in/opt-out feature toggles - a singleton-per-owner resource (no id in the route, +/// never listed), so a plain like rather than +/// . Available to every authenticated user (no +/// "MemberOnly" policy) - these are UI preferences, not a quota-relevant feature. +/// +[ApiController] +[Authorize] +[Route("api/user-preferences")] +public class UserPreferencesController(IUserPreferencesRepository repository, IDtoMapper mapper) : ControllerBase +{ + /// + /// The caller's preferences, defaulting to all-off when nothing has been saved yet. + /// + [HttpGet] + [ProducesResponseType(200)] + public async Task> Get() + { + var ownerId = this.GetUserId(); + var model = await repository.FindByOwnerIdAsync(ownerId) ?? new UserPreferencesModel { OwnerId = ownerId }; + return Ok(mapper.ToDto(model)); + } + + /// + /// Replaces the caller's preferences. + /// + [HttpPut] + [ProducesResponseType(204)] + public async Task Put(UserPreferencesDto dto) + { + var ownerId = this.GetUserId(); + var model = mapper.ToModel(dto); + model.OwnerId = ownerId; + await repository.UpsertAsync(model); + return NoContent(); + } +} diff --git a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs index c870a4c9..ed5773ec 100644 --- a/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs +++ b/src/WebApi/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -46,6 +46,7 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services, services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -56,6 +57,7 @@ internal static void AddMongoDbInfrastructure(this IServiceCollection services, services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/WebApi/Mappers/UserPreferencesDtoMapper.cs b/src/WebApi/Mappers/UserPreferencesDtoMapper.cs new file mode 100644 index 00000000..3aa90ffa --- /dev/null +++ b/src/WebApi/Mappers/UserPreferencesDtoMapper.cs @@ -0,0 +1,22 @@ +using Keeptrack.Domain.Models; +using Riok.Mapperly.Abstractions; + +namespace Keeptrack.WebApi.Mappers; + +/// +/// Unlike every other , is only ever used to +/// build a fresh model for UpsertAsync, never to preserve an existing document's Id - the +/// repository upserts by OwnerId, not Id, so a null Id here is harmless (Mongo keeps +/// the existing document's own _id on a matched replace). +/// +[Mapper] +public partial class UserPreferencesDtoMapper : IDtoMapper +{ + [MapperIgnoreTarget(nameof(UserPreferencesModel.Id))] + [MapValue(nameof(UserPreferencesModel.OwnerId), "")] + public partial UserPreferencesModel ToModel(UserPreferencesDto dto); + + [MapperIgnoreSource(nameof(UserPreferencesModel.Id))] + [MapperIgnoreSource(nameof(UserPreferencesModel.OwnerId))] + public partial UserPreferencesDto ToDto(UserPreferencesModel model); +} diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index 4332b87f..cadb92b9 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -28,6 +28,7 @@ builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.SongDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.PlaylistDtoMapper>(); builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.VideoGameDtoMapper>(); +builder.Services.AddSingleton, Keeptrack.WebApi.Mappers.UserPreferencesDtoMapper>(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs b/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs new file mode 100644 index 00000000..2fb26739 --- /dev/null +++ b/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs @@ -0,0 +1,50 @@ +using System.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using Keeptrack.WebApi.Contracts.Dto; +using Keeptrack.WebApi.IntegrationTests.Hosting; +using Xunit; + +namespace Keeptrack.WebApi.IntegrationTests.Resources; + +/// +/// Covers the singleton-per-owner preferences resource: the "nothing saved yet" default, and the +/// upsert-by-owner-id persistence the repository relies on (a first PUT inserts, a later one updates the +/// same document rather than colliding with the unique owner_id index). +/// +public class UserPreferencesResourceTest(KestrelWebAppFactory factory) + : ResourceTestBase(factory) +{ + private const string ResourceEndpoint = "api/user-preferences"; + + [Fact] + public async Task UserPreferences_RequireAuthentication() + { + await GetAsync($"/{ResourceEndpoint}", HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Get_ReturnsAllFalseDefaults_WhenNothingWasEverSaved() + { + await Authenticate(); + + var preferences = await GetAsync($"/{ResourceEndpoint}"); + + preferences.ShowChasseAuxLivresLink.Should().BeFalse(); + } + + [Fact] + public async Task Put_ThenGet_RoundTripsTheSavedValue() + { + await Authenticate(); + + await PutAsync($"/{ResourceEndpoint}", new UserPreferencesDto { ShowChasseAuxLivresLink = true }); + var afterFirstSave = await GetAsync($"/{ResourceEndpoint}"); + afterFirstSave.ShowChasseAuxLivresLink.Should().BeTrue(); + + // a second PUT must update the same document (upsert by owner_id), not collide with the unique index + await PutAsync($"/{ResourceEndpoint}", new UserPreferencesDto { ShowChasseAuxLivresLink = false }); + var afterSecondSave = await GetAsync($"/{ResourceEndpoint}"); + afterSecondSave.ShowChasseAuxLivresLink.Should().BeFalse(); + } +} From 764875f86447fb78dfa13bb31f50f2b8426e1ba1 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 22:49:25 +0200 Subject: [PATCH 20/35] Removing samples/angular folder --- samples/angular/.editorconfig | 14 - samples/angular/.eslintrc.json | 57 - samples/angular/.gitignore | 55 - samples/angular/README.md | 66 - samples/angular/angular.json | 191 - samples/angular/firebase.json | 19 - samples/angular/karma.conf.js | 37 - samples/angular/package-lock.json | 12309 ---------------- samples/angular/package.json | 66 - samples/angular/src/app/app.component.html | 6 - samples/angular/src/app/app.component.ts | 15 - samples/angular/src/app/app.routes.ts | 20 - .../angular/src/app/backend/backend.module.ts | 16 - .../app/backend/services/book.service.spec.ts | 37 - .../src/app/backend/services/book.service.ts | 37 - .../services/car-history.service.spec.ts | 12 - .../backend/services/car-history.service.ts | 7 - .../app/backend/services/data.interface.ts | 9 - .../backend/services/movie.service.spec.ts | 37 - .../src/app/backend/services/movie.service.ts | 38 - .../backend/services/tv-show.service.spec.ts | 36 - .../app/backend/services/tv-show.service.ts | 34 - .../services/video-game.service.spec.ts | 35 - .../backend/services/video-game.service.ts | 40 - .../src/app/backend/types/backend-data.d.ts | 4 - .../angular/src/app/backend/types/book.d.ts | 10 - .../src/app/backend/types/car-history.d.ts | 20 - .../angular/src/app/backend/types/car.d.ts | 6 - .../angular/src/app/backend/types/movie.d.ts | 10 - .../src/app/backend/types/tv-show.d.ts | 7 - .../src/app/backend/types/video-game.d.ts | 11 - .../angular/src/app/home/home.component.html | 9 - .../angular/src/app/home/home.component.ts | 10 - .../src/app/interceptors/auth.interceptor.ts | 21 - .../src/app/inventory/base/data.component.ts | 81 - .../app/inventory/book/book.component.html | 129 - .../app/inventory/book/book.component.spec.ts | 32 - .../src/app/inventory/book/book.component.ts | 26 - .../src/app/inventory/car/car.component.html | 1 - .../app/inventory/car/car.component.spec.ts | 25 - .../src/app/inventory/car/car.component.ts | 10 - .../src/app/inventory/inventory.module.ts | 23 - .../app/inventory/movie/movie.component.html | 104 - .../inventory/movie/movie.component.spec.ts | 32 - .../app/inventory/movie/movie.component.ts | 23 - .../inventory/tv-show/tv-show.component.html | 59 - .../tv-show/tv-show.component.spec.ts | 33 - .../inventory/tv-show/tv-show.component.ts | 21 - .../video-game/video-game.component.html | 175 - .../video-game/video-game.component.spec.ts | 32 - .../video-game/video-game.component.ts | 25 - .../app/layout/header/header.component.css | 18 - .../app/layout/header/header.component.html | 45 - .../src/app/layout/header/header.component.ts | 54 - .../angular/src/app/layout/layout.module.ts | 12 - .../src/app/user/login/login.component.html | 3 - .../app/user/login/login.component.spec.ts | 30 - .../src/app/user/login/login.component.ts | 17 - .../angular/src/app/user/models/user.model.ts | 4 - .../services/authenticate.service.spec.ts | 30 - .../app/user/services/authenticate.service.ts | 24 - samples/angular/src/app/user/user.module.ts | 16 - samples/angular/src/assets/.gitkeep | 0 .../angular/src/environments/environment.ts | 26 - samples/angular/src/favicon.ico | Bin 948 -> 0 bytes samples/angular/src/index.html | 13 - samples/angular/src/main.ts | 27 - samples/angular/src/styles.css | 18 - samples/angular/src/web.config | 38 - samples/angular/tsconfig.app.json | 13 - samples/angular/tsconfig.json | 33 - samples/angular/tsconfig.spec.json | 13 - 72 files changed, 14566 deletions(-) delete mode 100644 samples/angular/.editorconfig delete mode 100644 samples/angular/.eslintrc.json delete mode 100644 samples/angular/.gitignore delete mode 100644 samples/angular/README.md delete mode 100644 samples/angular/angular.json delete mode 100644 samples/angular/firebase.json delete mode 100644 samples/angular/karma.conf.js delete mode 100644 samples/angular/package-lock.json delete mode 100644 samples/angular/package.json delete mode 100644 samples/angular/src/app/app.component.html delete mode 100644 samples/angular/src/app/app.component.ts delete mode 100644 samples/angular/src/app/app.routes.ts delete mode 100644 samples/angular/src/app/backend/backend.module.ts delete mode 100644 samples/angular/src/app/backend/services/book.service.spec.ts delete mode 100644 samples/angular/src/app/backend/services/book.service.ts delete mode 100644 samples/angular/src/app/backend/services/car-history.service.spec.ts delete mode 100644 samples/angular/src/app/backend/services/car-history.service.ts delete mode 100644 samples/angular/src/app/backend/services/data.interface.ts delete mode 100644 samples/angular/src/app/backend/services/movie.service.spec.ts delete mode 100644 samples/angular/src/app/backend/services/movie.service.ts delete mode 100644 samples/angular/src/app/backend/services/tv-show.service.spec.ts delete mode 100644 samples/angular/src/app/backend/services/tv-show.service.ts delete mode 100644 samples/angular/src/app/backend/services/video-game.service.spec.ts delete mode 100644 samples/angular/src/app/backend/services/video-game.service.ts delete mode 100644 samples/angular/src/app/backend/types/backend-data.d.ts delete mode 100644 samples/angular/src/app/backend/types/book.d.ts delete mode 100644 samples/angular/src/app/backend/types/car-history.d.ts delete mode 100644 samples/angular/src/app/backend/types/car.d.ts delete mode 100644 samples/angular/src/app/backend/types/movie.d.ts delete mode 100644 samples/angular/src/app/backend/types/tv-show.d.ts delete mode 100644 samples/angular/src/app/backend/types/video-game.d.ts delete mode 100644 samples/angular/src/app/home/home.component.html delete mode 100644 samples/angular/src/app/home/home.component.ts delete mode 100644 samples/angular/src/app/interceptors/auth.interceptor.ts delete mode 100644 samples/angular/src/app/inventory/base/data.component.ts delete mode 100644 samples/angular/src/app/inventory/book/book.component.html delete mode 100644 samples/angular/src/app/inventory/book/book.component.spec.ts delete mode 100644 samples/angular/src/app/inventory/book/book.component.ts delete mode 100644 samples/angular/src/app/inventory/car/car.component.html delete mode 100644 samples/angular/src/app/inventory/car/car.component.spec.ts delete mode 100644 samples/angular/src/app/inventory/car/car.component.ts delete mode 100644 samples/angular/src/app/inventory/inventory.module.ts delete mode 100644 samples/angular/src/app/inventory/movie/movie.component.html delete mode 100644 samples/angular/src/app/inventory/movie/movie.component.spec.ts delete mode 100644 samples/angular/src/app/inventory/movie/movie.component.ts delete mode 100644 samples/angular/src/app/inventory/tv-show/tv-show.component.html delete mode 100644 samples/angular/src/app/inventory/tv-show/tv-show.component.spec.ts delete mode 100644 samples/angular/src/app/inventory/tv-show/tv-show.component.ts delete mode 100644 samples/angular/src/app/inventory/video-game/video-game.component.html delete mode 100644 samples/angular/src/app/inventory/video-game/video-game.component.spec.ts delete mode 100644 samples/angular/src/app/inventory/video-game/video-game.component.ts delete mode 100644 samples/angular/src/app/layout/header/header.component.css delete mode 100644 samples/angular/src/app/layout/header/header.component.html delete mode 100644 samples/angular/src/app/layout/header/header.component.ts delete mode 100644 samples/angular/src/app/layout/layout.module.ts delete mode 100644 samples/angular/src/app/user/login/login.component.html delete mode 100644 samples/angular/src/app/user/login/login.component.spec.ts delete mode 100644 samples/angular/src/app/user/login/login.component.ts delete mode 100644 samples/angular/src/app/user/models/user.model.ts delete mode 100644 samples/angular/src/app/user/services/authenticate.service.spec.ts delete mode 100644 samples/angular/src/app/user/services/authenticate.service.ts delete mode 100644 samples/angular/src/app/user/user.module.ts delete mode 100644 samples/angular/src/assets/.gitkeep delete mode 100644 samples/angular/src/environments/environment.ts delete mode 100644 samples/angular/src/favicon.ico delete mode 100644 samples/angular/src/index.html delete mode 100644 samples/angular/src/main.ts delete mode 100644 samples/angular/src/styles.css delete mode 100644 samples/angular/src/web.config delete mode 100644 samples/angular/tsconfig.app.json delete mode 100644 samples/angular/tsconfig.json delete mode 100644 samples/angular/tsconfig.spec.json diff --git a/samples/angular/.editorconfig b/samples/angular/.editorconfig deleted file mode 100644 index d0dcd4a4..00000000 --- a/samples/angular/.editorconfig +++ /dev/null @@ -1,14 +0,0 @@ -# Editor configuration, see http://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true -end_of_line = lf - -[*.md] -max_line_length = off -trim_trailing_whitespace = false diff --git a/samples/angular/.eslintrc.json b/samples/angular/.eslintrc.json deleted file mode 100644 index 0ac5f715..00000000 --- a/samples/angular/.eslintrc.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "root": true, - "ignorePatterns": [ - "dist", - "results", - "coverage" - ], - "parserOptions": { - "ecmaVersion": 2020 - }, - "overrides": [ - { - "files": [ - "*.ts" - ], - "parserOptions": { - "project": [ - "tsconfig.json", - "e2e/tsconfig.json" - ], - "createDefaultProgram": true - }, - "extends": [ - "plugin:@angular-eslint/recommended", - "eslint:recommended", - "plugin:@typescript-eslint/recommended" - ], - "rules": { - "@typescript-eslint/consistent-type-definitions": "error", - "@typescript-eslint/dot-notation": "off", - "@typescript-eslint/explicit-member-accessibility": [ - "off", - { - "accessibility": "explicit" - } - ], - "@typescript-eslint/naming-convention": "warn", - "brace-style": [ - "error", - "1tbs" - ], - "id-blacklist": "off", - "id-match": "off", - "no-underscore-dangle": "off" - } - }, - { - "files": [ - "*.html" - ], - "extends": [ - "plugin:@angular-eslint/template/recommended" - ], - "rules": {} - } - ] -} diff --git a/samples/angular/.gitignore b/samples/angular/.gitignore deleted file mode 100644 index af4a6f0d..00000000 --- a/samples/angular/.gitignore +++ /dev/null @@ -1,55 +0,0 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. - -# compiled output -/dist -/dist-server -/tmp -/out-tsc - -# dependencies -/node_modules - -# IDEs and editors -/.idea -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# IDE - VSCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -# misc -/.angular/cache -/.sass-cache -/connect.lock -/coverage -/libpeerconnection.log -npm-debug.log -yarn-error.log -testem.log -/typings - -# System Files -.DS_Store -Thumbs.db - -# Firebase -.firebaserc - -# Angular development config -environment.dev.ts -environment.prod.ts - -# Temporary files -/temp.txt - -# Test report files -/TESTS*.xml -/coverage diff --git a/samples/angular/README.md b/samples/angular/README.md deleted file mode 100644 index 520fe3fb..00000000 --- a/samples/angular/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Keeptrack Angular frontend application - -This project was generated with [Angular CLI](https://github.com/angular/angular-cli). - -## Local setup - -### Requirements - -* [NPM (Node.js)](https://nodejs.org/en/) -* [Angular CLI](https://cli.angular.io/): `npm install -g @angular/cli` -* [Firebase CLI](https://www.npmjs.com/package/firebase-tools): `npm install -g firebase-tools` - -### Configuration - -* Create a file `src/environments/environment.dev.ts` and update it (this file is not purposed to node save secret values) - * Open your project in the [Firebase console](https://console.firebase.google.com/), go in the properties pages (General tab), inside "Your apps" part you'll have all Firebase information - * Get the value from your local API URL (`https://localhost:5011`) - -### Installation of package - -Run `npm install` to load all dependencies. - -### Development server - -Run `npm start` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. - -_Warning_: you may encounter CORS issues while testing with Firefox, use Chrome for local debug - -### Code scaffolding - -Run `ng generate component / --module=` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. - -### Build - -Run `npm run build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. - -### Running unit tests - -Run `npm run test` to execute the unit tests via [Karma](https://karma-runner.github.io). - -### Running end-to-end tests - -Run `npm run e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). - -### Running linter - -Run `npm run lint` to validate source code standards. - -### Further help - -To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). - -### Update procedure - -* Update NPM: `npm install --global npm` -* Follow procedure described by [update.angular.io](https://update.angular.io/): `ng update ` - -### Firebase guide - -* [Getting started with Firebase Authentication](https://github.com/angular/angularfire/blob/master/docs/auth/getting-started.md) -* [angularfire/samples/compat](https://github.com/angular/angularfire/tree/master/samples/compat) - -### Graphic design - -* [Bootstrap v4.6](https://getbootstrap.com/docs/4.6/getting-started/introduction/) -* [Font Awesome v4.7.0](https://fontawesome.bootstrapcheatsheets.com/) diff --git a/samples/angular/angular.json b/samples/angular/angular.json deleted file mode 100644 index 52a102ca..00000000 --- a/samples/angular/angular.json +++ /dev/null @@ -1,191 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "newProjectRoot": "projects", - "projects": { - "AngularWebApp": { - "projectType": "application", - "schematics": { - "@schematics/angular:component": { - "standalone": true - }, - "@schematics/angular:directive": { - "standalone": true - }, - "@schematics/angular:pipe": { - "standalone": true - } - }, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "progress": false, - "outputPath": { - "base": "dist" - }, - "index": "src/index.html", - "polyfills": [ - "@angular/localize/init", - "zone.js" - ], - "tsConfig": "tsconfig.app.json", - "assets": [ - "src/assets", - "src/web.config" - ], - "styles": [ - "node_modules/bootstrap/dist/css/bootstrap.min.css", - "node_modules/font-awesome/css/font-awesome.css", - "src/styles.css" - ], - "scripts": [], - "extractLicenses": false, - "sourceMap": true, - "optimization": false, - "namedChunks": true, - "browser": "src/main.ts" - }, - "configurations": { - "dev": { - "budgets": [ - { - "type": "anyComponentStyle", - "maximumWarning": "6kb" - } - ], - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.dev.ts" - } - ] - }, - "production": { - "budgets": [ - { - "type": "anyComponentStyle", - "maximumWarning": "6kb" - } - ], - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.prod.ts" - } - ], - "optimization": true, - "outputHashing": "all", - "sourceMap": false, - "namedChunks": false, - "extractLicenses": true - } - }, - "defaultConfiguration": "" - }, - "serve": { - "builder": "@angular/build:dev-server", - "options": { - "buildTarget": "AngularWebApp:build" - }, - "configurations": { - "dev": { - "buildTarget": "AngularWebApp:build:dev" - }, - "production": { - "buildTarget": "AngularWebApp:build:production" - } - } - }, - "extract-i18n": { - "builder": "@angular/build:extract-i18n", - "options": { - "buildTarget": "AngularWebApp:build" - } - }, - "test": { - "builder": "@angular/build:karma", - "options": { - "polyfills": [ - "@angular/localize/init", - "zone.js", - "zone.js/testing" - ], - "tsConfig": "tsconfig.spec.json", - "karmaConfig": "karma.conf.js", - "codeCoverageExclude": [ - "**/models/*.ts", - "**/app.module.ts", - "**/app.routes.ts" - ], - "assets": [ - "src/favicon.ico", - "src/assets" - ], - "styles": [ - "src/styles.css" - ], - "scripts": [] - }, - "configurations": { - "dev": { - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.dev.ts" - } - ] - } - } - }, - "lint": { - "builder": "@angular-eslint/builder:lint", - "options": { - "lintFilePatterns": [ - "src/**/*.ts", - "src/**/*.html" - ] - } - } - } - } - }, - "schematics": { - "@angular-eslint/schematics:application": { - "setParserOptionsProject": true - }, - "@angular-eslint/schematics:library": { - "setParserOptionsProject": true - }, - "@schematics/angular:component": { - "type": "component" - }, - "@schematics/angular:directive": { - "type": "directive" - }, - "@schematics/angular:service": { - "type": "service" - }, - "@schematics/angular:guard": { - "typeSeparator": "." - }, - "@schematics/angular:interceptor": { - "typeSeparator": "." - }, - "@schematics/angular:module": { - "typeSeparator": "." - }, - "@schematics/angular:pipe": { - "typeSeparator": "." - }, - "@schematics/angular:resolver": { - "typeSeparator": "." - } - }, - "cli": { - "analytics": false - } -} diff --git a/samples/angular/firebase.json b/samples/angular/firebase.json deleted file mode 100644 index 189585aa..00000000 --- a/samples/angular/firebase.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "hosting": [ - { - "target": "AngularWebApp", - "public": "dist", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], - "rewrites": [ - { - "source": "**", - "destination": "/index.html" - } - ] - } - ] -} diff --git a/samples/angular/karma.conf.js b/samples/angular/karma.conf.js deleted file mode 100644 index fc4f986d..00000000 --- a/samples/angular/karma.conf.js +++ /dev/null @@ -1,37 +0,0 @@ -module.exports = function (config) { - config.set({ - basePath: '', - frameworks: ['jasmine', '@angular-devkit/build-angular'], - plugins: [ - require('karma-jasmine'), - require('karma-coverage'), - require('karma-chrome-launcher'), - require('karma-jasmine-html-reporter'), - require('karma-junit-reporter') - ], - client: { - jasmine: {}, - clearContext: false // leave Jasmine Spec Runner output visible in browser - }, - preprocessors: { - 'src/**/*.js': ['coverage'] - }, - coverageReporter: { - dir: require('path').join(__dirname, '/coverage'), - reporters: [ - { type: 'html', subdir: 'html' }, - { type: 'lcovonly', subdir: '.', file: 'lcov.info' } - ] - }, - junitReporter: { - outputDir: require('path').join(__dirname, '.'), - }, - reporters: ['progress', 'kjhtml', 'junit', 'coverage'], - port: 9876, - colors: true, - logLevel: config.LOG_INFO, - autoWatch: true, - browsers: ['Chrome'], // 'Chrome', 'ChromeHeadless' - singleRun: false - }); -}; diff --git a/samples/angular/package-lock.json b/samples/angular/package-lock.json deleted file mode 100644 index b6a9c239..00000000 --- a/samples/angular/package-lock.json +++ /dev/null @@ -1,12309 +0,0 @@ -{ - "name": "keeptrack-angularwebapp", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "keeptrack-angularwebapp", - "version": "0.1.0", - "dependencies": { - "@angular/animations": "^21.2.1", - "@angular/common": "^21.2.1", - "@angular/compiler": "^21.2.1", - "@angular/core": "^21.2.1", - "@angular/fire": "^21.0.0-rc.0", - "@angular/forms": "^21.2.1", - "@angular/localize": "^21.2.1", - "@angular/platform-browser": "^21.2.1", - "@angular/platform-browser-dynamic": "^21.2.1", - "@angular/platform-server": "^21.2.1", - "@angular/router": "^21.2.1", - "@popperjs/core": "~2.11.8", - "bootstrap": "~5.3.2", - "core-js": "^3.48.0", - "font-awesome": "^4.7.0", - "rxjs": "~7.8.2", - "tslib": "^2.8.1", - "zone.js": "~0.16.1" - }, - "devDependencies": { - "@angular-devkit/architect": "^0.2102.1", - "@angular-eslint/builder": "^21.3.0", - "@angular-eslint/eslint-plugin": "^21.3.0", - "@angular-eslint/eslint-plugin-template": "^21.3.0", - "@angular-eslint/schematics": "^21.3.0", - "@angular-eslint/template-parser": "^21.3.0", - "@angular/build": "^21.2.1", - "@angular/cli": "^21.2.1", - "@angular/compiler-cli": "^21.2.1", - "@angular/language-service": "^21.2.1", - "@types/jasmine": "~6.0.0", - "@types/node": "^22.13.14", - "acorn": "~8.16.0", - "eslint": "^10.0.3", - "fuzzy": "~0.1.3", - "glob": "^13.0.6", - "jasmine": "~6.1.0", - "jasmine-core": "~6.1.0", - "jasmine-spec-reporter": "~7.0.0", - "karma": "~6.4.4", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.1", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.2.0", - "karma-junit-reporter": "~2.0.1", - "typescript": "~5.9.3" - }, - "optionalDependencies": { - "ts-node": "~10.7.0" - } - }, - "node_modules/@algolia/abtesting": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", - "integrity": "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.1.tgz", - "integrity": "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.1.tgz", - "integrity": "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.1.tgz", - "integrity": "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.1.tgz", - "integrity": "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.1.tgz", - "integrity": "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.1.tgz", - "integrity": "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", - "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.1.tgz", - "integrity": "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.1.tgz", - "integrity": "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.1.tgz", - "integrity": "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.1.tgz", - "integrity": "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.1.tgz", - "integrity": "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.1.tgz", - "integrity": "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@angular-devkit/architect": { - "version": "0.2102.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.1.tgz", - "integrity": "sha512-x2Qqz6oLYvEh9UBUG0AP1A4zROO/VP+k+zM9+4c2uZw1uqoBQFmutqgzncjVU7cR9R0RApgx9JRZHDFtQru68w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.1", - "rxjs": "7.8.2" - }, - "bin": { - "architect": "bin/cli.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/core": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.1.tgz", - "integrity": "sha512-TpXGjERqVPN8EPt7LdmWAwh0oNQ/6uWFutzGZiXhJy81n1zb1O1XrqhRAmvP1cAo5O+na6IV2JkkCmxL6F8GUg==", - "license": "MIT", - "dependencies": { - "ajv": "8.18.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.3", - "rxjs": "7.8.2", - "source-map": "0.7.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/schematics": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.1.tgz", - "integrity": "sha512-CWoamHaasAHMjHcYqxbj0tMnoXxdGotcAz2SpiuWtH28Lnf5xfbTaJn/lwdMP8Wdh4tgA+uYh2l45A5auCwmkw==", - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.1", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.21", - "ora": "9.3.0", - "rxjs": "7.8.2" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-eslint/builder": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-21.3.0.tgz", - "integrity": "sha512-26QUUouei52biUFAlJSrWNAU9tuF2miKwd8uHdxWwCF31xz+OxC5+NfudWvt1AFaYow7gWueX1QX3rNNtSPDrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/architect": ">= 0.2100.0 < 0.2200.0", - "@angular-devkit/core": ">= 21.0.0 < 22.0.0" - }, - "peerDependencies": { - "@angular/cli": ">= 21.0.0 < 22.0.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-21.3.0.tgz", - "integrity": "sha512-l521I24J9gJxyMbRkrM24Tc7W8J8BP+TDAmVs2nT8+lXbS3kg8QpWBRtd+hNUgq6o+vt+lKBkytnEfu8OiqeRg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@angular-eslint/eslint-plugin": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-21.3.0.tgz", - "integrity": "sha512-Whf/AUUBekOlfSJRS78m76YGrBQAZ3waXE7oOdlW5xEQvn8jBDN9EGuNnjg/syZzvzjK4ZpYC4g1XYXrc+fQIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.0", - "@angular-eslint/utils": "21.3.0", - "ts-api-utils": "^2.1.0" - }, - "peerDependencies": { - "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-21.3.0.tgz", - "integrity": "sha512-lVixd/KypPWgA/5/pUOhJV9MTcaHjYZEqyOi+IiLk+h+maGxn6/s6Ot+20n+XGS85zAgOY+qUw6EEQ11hoojIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.0", - "@angular-eslint/utils": "21.3.0", - "aria-query": "5.3.2", - "axobject-query": "4.1.0" - }, - "peerDependencies": { - "@angular-eslint/template-parser": "21.3.0", - "@typescript-eslint/types": "^7.11.0 || ^8.0.0", - "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/schematics": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-21.3.0.tgz", - "integrity": "sha512-8deU/zVY9f8k8kAQQ9PL130ox2VlrZw3fMxgsPNAY5tjQ0xk0J2YVSszYHhcqdMGG1J01IsxIjvQaJ4pFfEmMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": ">= 21.0.0 < 22.0.0", - "@angular-devkit/schematics": ">= 21.0.0 < 22.0.0", - "@angular-eslint/eslint-plugin": "21.3.0", - "@angular-eslint/eslint-plugin-template": "21.3.0", - "ignore": "7.0.5", - "semver": "7.7.4", - "strip-json-comments": "3.1.1" - }, - "peerDependencies": { - "@angular/cli": ">= 21.0.0 < 22.0.0" - } - }, - "node_modules/@angular-eslint/template-parser": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-21.3.0.tgz", - "integrity": "sha512-ysyou1zAY6M6rSZNdIcYKGd4nk6TCapamyFNB3ivmTlVZ0O35TS9o/rJ0aUttuHgDp+Ysgs3ql+LA746PXgCyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.0", - "eslint-scope": "^9.1.1" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/utils": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-21.3.0.tgz", - "integrity": "sha512-oNigH6w3l+owTMboj/uFG0tHOy43uH8BpQRtBOQL1/s2+5in/BJ2Fjobv3SyizxTgeJ1FhRefbkT8GmVjK7jAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "21.3.0" - }, - "peerDependencies": { - "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": "*" - } - }, - "node_modules/@angular/animations": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.1.tgz", - "integrity": "sha512-zT/S29pUTbziCLvZ2itBdNWd5i8tsXexofH7KA4n2yvYmK1EhNpE7TlHRjghmsHgtDt4VnGiMW4zXEyrl05Dwg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/core": "21.2.1" - } - }, - "node_modules/@angular/build": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.1.tgz", - "integrity": "sha512-cUpLNHJp9taII/FOcJHHfQYlMcZSRaf6eIxgSNS6Xfx1CeGoJNDN+J8+GFk+H1CPJt1EvbfyZ+dE5DbsgTD/QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.1", - "@babel/core": "7.29.0", - "@babel/helper-annotate-as-pure": "7.27.3", - "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "5.1.21", - "@vitejs/plugin-basic-ssl": "2.1.4", - "beasties": "0.4.1", - "browserslist": "^4.26.0", - "esbuild": "0.27.3", - "https-proxy-agent": "7.0.6", - "istanbul-lib-instrument": "6.0.3", - "jsonc-parser": "3.3.1", - "listr2": "9.0.5", - "magic-string": "0.30.21", - "mrmime": "2.0.1", - "parse5-html-rewriting-stream": "8.0.0", - "picomatch": "4.0.3", - "piscina": "5.1.4", - "rolldown": "1.0.0-rc.4", - "sass": "1.97.3", - "semver": "7.7.4", - "source-map-support": "0.5.21", - "tinyglobby": "0.2.15", - "undici": "7.22.0", - "vite": "7.3.1", - "watchpack": "2.5.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "optionalDependencies": { - "lmdb": "3.5.1" - }, - "peerDependencies": { - "@angular/compiler": "^21.0.0", - "@angular/compiler-cli": "^21.0.0", - "@angular/core": "^21.0.0", - "@angular/localize": "^21.0.0", - "@angular/platform-browser": "^21.0.0", - "@angular/platform-server": "^21.0.0", - "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.1", - "karma": "^6.4.0", - "less": "^4.2.0", - "ng-packagr": "^21.0.0", - "postcss": "^8.4.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "tslib": "^2.3.0", - "typescript": ">=5.9 <6.0", - "vitest": "^4.0.8" - }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true - }, - "@angular/localize": { - "optional": true - }, - "@angular/platform-browser": { - "optional": true - }, - "@angular/platform-server": { - "optional": true - }, - "@angular/service-worker": { - "optional": true - }, - "@angular/ssr": { - "optional": true - }, - "karma": { - "optional": true - }, - "less": { - "optional": true - }, - "ng-packagr": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tailwindcss": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@angular/cli": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.1.tgz", - "integrity": "sha512-5SRfMTgwFj1zXOpfeZWHsxZBni0J4Xz7/CbewG47D6DmbstOrSdgt6eNzJ62R650t0G9dpri2YvToZgImtbjOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/architect": "0.2102.1", - "@angular-devkit/core": "21.2.1", - "@angular-devkit/schematics": "21.2.1", - "@inquirer/prompts": "7.10.1", - "@listr2/prompt-adapter-inquirer": "3.0.5", - "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.1", - "@yarnpkg/lockfile": "1.1.0", - "algoliasearch": "5.48.1", - "ini": "6.0.0", - "jsonc-parser": "3.3.1", - "listr2": "9.0.5", - "npm-package-arg": "13.0.2", - "pacote": "21.3.1", - "parse5-html-rewriting-stream": "8.0.0", - "semver": "7.7.4", - "yargs": "18.0.0", - "zod": "4.3.6" - }, - "bin": { - "ng": "bin/ng.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/common": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.1.tgz", - "integrity": "sha512-xhv2i1Q9s1kpGbGsfj+o36+XUC/TQLcZyRuRxn3GwaN7Rv34FabC88ycpvoE+sW/txj4JRx9yPA0dRSZjwZ+Gg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/core": "21.2.1", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/compiler": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.1.tgz", - "integrity": "sha512-FxWaSaii1vfHIFA+JksqQ8NGB2frfqCrs7Ju50a44kbwR4fmanfn/VsiS/CbwBp9vcyT/Br9X/jAG4RuK/U2nw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@angular/compiler-cli": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.1.tgz", - "integrity": "sha512-qYCWLGtEju4cDtYLi4ZzbwKoF0lcGs+Lc31kuESvAzYvWNgk2EUOtwWo8kbgpAzAwSYodtxW6Q90iWEwfU6elw==", - "license": "MIT", - "dependencies": { - "@babel/core": "7.29.0", - "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^5.0.0", - "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.2.0", - "semver": "^7.0.0", - "tslib": "^2.3.0", - "yargs": "^18.0.0" - }, - "bin": { - "ng-xi18n": "bundles/src/bin/ng_xi18n.js", - "ngc": "bundles/src/bin/ngc.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/compiler": "21.2.1", - "typescript": ">=5.9 <6.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@angular/core": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.1.tgz", - "integrity": "sha512-pFTbg03s2ZI5cHNT+eWsGjwIIKiYkeAnodFbCAHjwFi9KCEYlTykFLjr9lcpGrBddfmAH7GE08Q73vgmsdcNHw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/compiler": "21.2.1", - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0 || ~0.16.0" - }, - "peerDependenciesMeta": { - "@angular/compiler": { - "optional": true - }, - "zone.js": { - "optional": true - } - } - }, - "node_modules/@angular/fire": { - "version": "21.0.0-rc.0-canary.ac3dd7c", - "resolved": "https://registry.npmjs.org/@angular/fire/-/fire-21.0.0-rc.0-canary.ac3dd7c.tgz", - "integrity": "sha512-Z1T9FrA8pd4JxUdQt1kDpsPGuXmqJSofsyiIZEJWboG4K1rezqN2JsB3cVGucIJ9uLljaFdvAtXYc+unBmAADQ==", - "license": "MIT", - "dependencies": { - "@angular-devkit/schematics": "^21.0.0", - "@schematics/angular": "^21.0.0", - "firebase": "^12.4.0", - "rxfire": "^6.1.0", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^21.0.0", - "@angular/core": "^21.0.0", - "@angular/platform-browser": "^21.0.0", - "@angular/platform-browser-dynamic": "^21.0.0", - "@angular/platform-server": "^21.0.0", - "firebase-tools": "^14.0.0", - "rxjs": "~7.8.0" - }, - "peerDependenciesMeta": { - "@angular/platform-server": { - "optional": true - }, - "firebase-tools": { - "optional": true - } - } - }, - "node_modules/@angular/fire/node_modules/@firebase/ai": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.9.0.tgz", - "integrity": "sha512-NPvBBuvdGo9x3esnABAucFYmqbBmXvyTMimBq2PCuLZbdANZoHzGlx7vfzbwNDaEtCBq4RGGNMliLIv6bZ+PtA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@firebase/app-types": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/analytics": { - "version": "0.10.20", - "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.20.tgz", - "integrity": "sha512-adGTNVUWH5q66tI/OQuKLSN6mamPpfYhj0radlH2xt+3eL6NFPtXoOs+ulvs+UsmK27vNFx5FjRDfWk+TyduHg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/analytics-compat": { - "version": "0.2.26", - "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.26.tgz", - "integrity": "sha512-0j2ruLOoVSwwcXAF53AMoniJKnkwiTjGVfic5LDzqiRkR13vb5j6TXMeix787zbLeQtN/m1883Yv1TxI0gItbA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/analytics": "0.10.20", - "@firebase/analytics-types": "0.8.3", - "@firebase/component": "0.7.1", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/app": { - "version": "0.14.9", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.9.tgz", - "integrity": "sha512-3gtUX0e584MYkKBQMgSECMvE1Dwzg+eONefDQ0wxVSe5YMBsZwdN5pL7UapwWBlV8+i8QCztF9TP947tEjZAGA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/app-check": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.1.tgz", - "integrity": "sha512-gmKfwQ2k8aUQlOyRshc+fOQLq0OwUmibIZvpuY1RDNu2ho0aTMlwxOuEiJeYOs7AxzhSx7gnXPFNsXCFbnvXUQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/app-check-compat": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.1.tgz", - "integrity": "sha512-yjSvSl5B1u4CirnxhzirN1uiTRCRfx+/qtfbyeyI+8Cx8Cw1RWAIO/OqytPSVwLYbJJ1vEC3EHfxazRaMoWKaA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check": "0.11.1", - "@firebase/app-check-types": "0.5.3", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/app-compat": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.9.tgz", - "integrity": "sha512-e5LzqjO69/N2z7XcJeuMzIp4wWnW696dQeaHAUpQvGk89gIWHAIvG6W+mA3UotGW6jBoqdppEJ9DnuwbcBByug==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app": "0.14.9", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/auth": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.12.1.tgz", - "integrity": "sha512-nXKj7d5bMBlnq6XpcQQpmnSVwEeHBkoVbY/+Wk0P1ebLSICoH4XPtvKOFlXKfIHmcS84mLQ99fk3njlDGKSDtw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@react-native-async-storage/async-storage": "^2.2.0" - }, - "peerDependenciesMeta": { - "@react-native-async-storage/async-storage": { - "optional": true - } - } - }, - "node_modules/@angular/fire/node_modules/@firebase/auth-compat": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.3.tgz", - "integrity": "sha512-nHOkupcYuGVxI1AJJ/OBhLPaRokbP14Gq4nkkoVvf1yvuREEWqdnrYB/CdsSnPxHMAnn5wJIKngxBF9jNX7s/Q==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/auth": "1.12.1", - "@firebase/auth-types": "0.13.0", - "@firebase/component": "0.7.1", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/component": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.1.tgz", - "integrity": "sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/data-connect": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.4.0.tgz", - "integrity": "sha512-vLXM6WHNIR3VtEeYNUb/5GTsUOyl3Of4iWNZHBe1i9f88sYFnxybJNWVBjvJ7flhCyF8UdxGpzWcUnv6F5vGfg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/database": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.1.tgz", - "integrity": "sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/database-compat": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.1.tgz", - "integrity": "sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/database": "1.1.1", - "@firebase/database-types": "1.0.17", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/database-types": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.17.tgz", - "integrity": "sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.14.0" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/firestore": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.12.0.tgz", - "integrity": "sha512-PM47OyiiAAoAMB8kkq4Je14mTciaRoAPDd3ng3Ckqz9i2TX9D9LfxIRcNzP/OxzNV4uBKRq6lXoOggkJBQR3Gw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "@firebase/webchannel-wrapper": "1.0.5", - "@grpc/grpc-js": "~1.9.0", - "@grpc/proto-loader": "^0.7.8", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/firestore-compat": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.6.tgz", - "integrity": "sha512-NgVyR4hHHN2FvSNQOtbgBOuVsEdD/in30d9FKbEvvITiAChrBN2nBstmhfjI4EOTnHaP8zigwvkNYFI9yKGAkQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/firestore": "4.12.0", - "@firebase/firestore-types": "3.0.3", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/functions": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.2.tgz", - "integrity": "sha512-tHduUD+DeokM3NB1QbHCvEMoL16e8Z8JSkmuVA4ROoJKPxHn8ibnecHPO2e3nVCJR1D9OjuKvxz4gksfq92/ZQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.7.1", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/functions-compat": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.2.tgz", - "integrity": "sha512-YNxgnezvZDkqxqXa6cT7/oTeD4WXbxgIP7qZp4LFnathQv5o2omM6EoIhXiT9Ie5AoQDcIhG9Y3/dj+DFJGaGQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/functions": "0.13.2", - "@firebase/functions-types": "0.6.3", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/installations": { - "version": "0.6.20", - "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.20.tgz", - "integrity": "sha512-LOzvR7XHPbhS0YB5ANXhqXB5qZlntPpwU/4KFwhSNpXNsGk/sBQ9g5hepi0y0/MfenJLe2v7t644iGOOElQaHQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/util": "1.14.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/installations-compat": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.20.tgz", - "integrity": "sha512-9C9pL/DIEGucmoPj8PlZTnztbX3nhNj5RTYVpUM7wQq/UlHywaYv99969JU/WHLvi9ptzIogXYS9d1eZ6XFe9g==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", - "@firebase/installations-types": "0.5.3", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/logger": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", - "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/messaging": { - "version": "0.12.24", - "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.24.tgz", - "integrity": "sha512-UtKoubegAhHyehcB7iQjvQ8OVITThPbbWk3g2/2ze42PrQr6oe6OmCElYQkBrE5RDCeMTNucXejbdulrQ2XwVg==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.14.0", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/messaging-compat": { - "version": "0.2.24", - "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.24.tgz", - "integrity": "sha512-wXH8FrKbJvFuFe6v98TBhAtvgknxKIZtGM/wCVsfpOGmaAE80bD8tBxztl+uochjnFb9plihkd6mC4y7sZXSpA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/messaging": "0.12.24", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/performance": { - "version": "0.7.10", - "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.10.tgz", - "integrity": "sha512-8nRFld+Ntzp5cLKzZuG9g+kBaSn8Ks9dmn87UQGNFDygbmR6ebd8WawauEXiJjMj1n70ypkvAOdE+lzeyfXtGA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0", - "web-vitals": "^4.2.4" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/performance-compat": { - "version": "0.2.23", - "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.23.tgz", - "integrity": "sha512-c7qOAGBUAOpIuUlHu1axWcrCVtIYKPMhH0lMnoCDWnPwn1HcPuPUBVTWETbC7UWw71RMJF8DpirfWXzMWJQfgA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/performance": "0.7.10", - "@firebase/performance-types": "0.2.3", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/remote-config": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.1.tgz", - "integrity": "sha512-L86TReBnPiiJOWd7k9iaiE9f7rHtMpjAoYN0fH2ey2ZRzsOChHV0s5sYf1+IIUYzplzsE46pjlmAUNkRRKwHSQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/installations": "0.6.20", - "@firebase/logger": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/remote-config-compat": { - "version": "0.2.22", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.22.tgz", - "integrity": "sha512-uW/eNKKtRBot2gnCC5mnoy5Voo2wMzZuQ7dwqqGHU176fO9zFgMwKiRzk+aaC99NLrFk1KOmr0ZVheD+zdJmjQ==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/logger": "0.5.0", - "@firebase/remote-config": "0.8.1", - "@firebase/remote-config-types": "0.5.0", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/remote-config-types": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz", - "integrity": "sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg==", - "license": "Apache-2.0" - }, - "node_modules/@angular/fire/node_modules/@firebase/storage": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.1.tgz", - "integrity": "sha512-uIpYgBBsv1vIET+5xV20XT7wwqV+H4GFp6PBzfmLUcEgguS4SWNFof56Z3uOC2lNDh0KDda1UflYq2VwD9Nefw==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/storage-compat": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.1.tgz", - "integrity": "sha512-bgl3FHHfXAmBgzIK/Fps6Xyv2HiAQlSTov07CBL+RGGhrC5YIk4lruS8JVIC+UkujRdYvnf8cpQFGn2RCilJ/A==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/component": "0.7.1", - "@firebase/storage": "0.14.1", - "@firebase/storage-types": "0.8.3", - "@firebase/util": "1.14.0", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/util": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.14.0.tgz", - "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@angular/fire/node_modules/@firebase/webchannel-wrapper": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz", - "integrity": "sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw==", - "license": "Apache-2.0" - }, - "node_modules/@angular/fire/node_modules/firebase": { - "version": "12.10.0", - "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.10.0.tgz", - "integrity": "sha512-tAjHnEirksqWpa+NKDUSUMjulOnsTcsPC1X1rQ+gwPtjlhJS572na91CwaBXQJHXharIrfj7sw/okDkXOsphjA==", - "license": "Apache-2.0", - "dependencies": { - "@firebase/ai": "2.9.0", - "@firebase/analytics": "0.10.20", - "@firebase/analytics-compat": "0.2.26", - "@firebase/app": "0.14.9", - "@firebase/app-check": "0.11.1", - "@firebase/app-check-compat": "0.4.1", - "@firebase/app-compat": "0.5.9", - "@firebase/app-types": "0.9.3", - "@firebase/auth": "1.12.1", - "@firebase/auth-compat": "0.6.3", - "@firebase/data-connect": "0.4.0", - "@firebase/database": "1.1.1", - "@firebase/database-compat": "2.1.1", - "@firebase/firestore": "4.12.0", - "@firebase/firestore-compat": "0.4.6", - "@firebase/functions": "0.13.2", - "@firebase/functions-compat": "0.4.2", - "@firebase/installations": "0.6.20", - "@firebase/installations-compat": "0.2.20", - "@firebase/messaging": "0.12.24", - "@firebase/messaging-compat": "0.2.24", - "@firebase/performance": "0.7.10", - "@firebase/performance-compat": "0.2.23", - "@firebase/remote-config": "0.8.1", - "@firebase/remote-config-compat": "0.2.22", - "@firebase/storage": "0.14.1", - "@firebase/storage-compat": "0.4.1", - "@firebase/util": "1.14.0" - } - }, - "node_modules/@angular/forms": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.1.tgz", - "integrity": "sha512-6aqOPk9xoa0dfeUDeEbhaiPhmt6MQrdn59qbGAomn9RMXA925TrHbJhSIkp9tXc2Fr4aJRi8zkD/cdXEc1IYeA==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.1", - "@angular/core": "21.2.1", - "@angular/platform-browser": "21.2.1", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/language-service": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/language-service/-/language-service-21.2.1.tgz", - "integrity": "sha512-L8EaNhWDKMny18RURg/Ju2Dix2e7qLL/s2yDQrawgjQRmXAMnjimz10w/EiiG7FMK/Hj5fLycS5X8VITq1f2rg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@angular/localize": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-21.2.1.tgz", - "integrity": "sha512-2QsN33fLO3N/RRFfUxDKHMX/Y/2TH90Tx51Wi6hi1do9IJdlfEe1qBw+5F0g1F1CuFEYgZWMJdZIK7LPHpuDzw==", - "license": "MIT", - "dependencies": { - "@babel/core": "7.29.0", - "@types/babel__core": "7.20.5", - "tinyglobby": "^0.2.12", - "yargs": "^18.0.0" - }, - "bin": { - "localize-extract": "tools/bundles/src/extract/cli.js", - "localize-migrate": "tools/bundles/src/migrate/cli.js", - "localize-translate": "tools/bundles/src/translate/cli.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/compiler": "21.2.1", - "@angular/compiler-cli": "21.2.1" - } - }, - "node_modules/@angular/platform-browser": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.1.tgz", - "integrity": "sha512-k4SJLxIaLT26vLjLuFL+ho0BiG5PrdxEsjsXFC7w5iUhomeouzkHVTZ4t7gaLNKrdRD7QNtU4Faw0nL0yx0ZPQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/animations": "21.2.1", - "@angular/common": "21.2.1", - "@angular/core": "21.2.1" - }, - "peerDependenciesMeta": { - "@angular/animations": { - "optional": true - } - } - }, - "node_modules/@angular/platform-browser-dynamic": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.1.tgz", - "integrity": "sha512-J4KnrXjgSuk7KjEm79/RK1yyzR867sIyT5mcG6jx2KmkjspFJd4OeOux7Oj7lSBM7+nDEsKC9F6s0x3dC0hCPQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.1", - "@angular/compiler": "21.2.1", - "@angular/core": "21.2.1", - "@angular/platform-browser": "21.2.1" - } - }, - "node_modules/@angular/platform-server": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/platform-server/-/platform-server-21.2.1.tgz", - "integrity": "sha512-ezIY3hKl98JIjQcy+wq+38w9ln2bplQ8HFpEsaYySjPPu5TYCQ/z8HFxpvgqK80SVfvE7ijbgY+ALzKCE30Fjg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0", - "xhr2": "^0.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.1", - "@angular/compiler": "21.2.1", - "@angular/core": "21.2.1", - "@angular/platform-browser": "21.2.1", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/router": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.1.tgz", - "integrity": "sha512-FUKG+8ImQYxmlDUdAs7+VeS/VrBNrbo0zGiKkzVNU/bbcCyroKXJLXFtkFI3qmROiJNyIta2IMBCHJvIjLIMig==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.1", - "@angular/core": "21.2.1", - "@angular/platform-browser": "21.2.1", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "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==", - "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==", - "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==", - "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/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "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==", - "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-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "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==", - "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-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "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==", - "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==", - "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==", - "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-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "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==", - "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==", - "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==", - "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==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "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==", - "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==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@cspotcode/source-map-consumer": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz", - "integrity": "sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz", - "integrity": "sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@cspotcode/source-map-consumer": "0.8.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^3.0.3", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.1.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, - "node_modules/@firebase/ai": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", - "integrity": "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/analytics": { - "version": "0.10.17", - "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.17.tgz", - "integrity": "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/analytics-compat": { - "version": "0.2.23", - "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.23.tgz", - "integrity": "sha512-3AdO10RN18G5AzREPoFgYhW6vWXr3u+OYQv6pl3CX6Fky8QRk0AHurZlY3Q1xkXO0TDxIsdhO3y65HF7PBOJDw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/analytics": "0.10.17", - "@firebase/analytics-types": "0.8.3", - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/analytics-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", - "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app": { - "version": "0.13.2", - "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.2.tgz", - "integrity": "sha512-jwtMmJa1BXXDCiDx1vC6SFN/+HfYG53UkfJa6qeN5ogvOunzbFDO3wISZy5n9xgYFUrEP6M7e8EG++riHNTv9w==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app-check": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.1.tgz", - "integrity": "sha512-MgNdlms9Qb0oSny87pwpjKush9qUwCJhfmTJHDfrcKo4neLGiSeVE4qJkzP7EQTIUFKp84pbTxobSAXkiuQVYQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/app-check-compat": { - "version": "0.3.26", - "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.26.tgz", - "integrity": "sha512-PkX+XJMLDea6nmnopzFKlr+s2LMQGqdyT2DHdbx1v1dPSqOol2YzgpgymmhC67vitXVpNvS3m/AiWQWWhhRRPQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/app-check": "0.10.1", - "@firebase/app-check-types": "0.5.3", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/app-check-interop-types": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", - "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-check-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", - "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/app-compat": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.2.tgz", - "integrity": "sha512-LssbyKHlwLeiV8GBATyOyjmHcMpX/tFjzRUCS1jnwGAew1VsBB4fJowyS5Ud5LdFbYpJeS+IQoC+RQxpK7eH3Q==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/app": "0.13.2", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/app-types": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/auth": { - "version": "1.10.8", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.8.tgz", - "integrity": "sha512-GpuTz5ap8zumr/ocnPY57ZanX02COsXloY6Y/2LYPAuXYiaJRf6BAGDEdRq1BMjP93kqQnKNuKZUTMZbQ8MNYA==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@react-native-async-storage/async-storage": "^1.18.1" - }, - "peerDependenciesMeta": { - "@react-native-async-storage/async-storage": { - "optional": true - } - } - }, - "node_modules/@firebase/auth-compat": { - "version": "0.5.28", - "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.28.tgz", - "integrity": "sha512-HpMSo/cc6Y8IX7bkRIaPPqT//Jt83iWy5rmDWeThXQCAImstkdNo3giFLORJwrZw2ptiGkOij64EH1ztNJzc7Q==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/auth": "1.10.8", - "@firebase/auth-types": "0.13.0", - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/auth-interop-types": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", - "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/auth-types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", - "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/component": { - "version": "0.6.18", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.18.tgz", - "integrity": "sha512-n28kPCkE2dL2U28fSxZJjzPPVpKsQminJ6NrzcKXAI0E/lYC8YhfwpyllScqVEvAI3J2QgJZWYgrX+1qGI+SQQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/data-connect": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.10.tgz", - "integrity": "sha512-VMVk7zxIkgwlVQIWHOKFahmleIjiVFwFOjmakXPd/LDgaB/5vzwsB5DWIYo+3KhGxWpidQlR8geCIn39YflJIQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/database": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.20.tgz", - "integrity": "sha512-H9Rpj1pQ1yc9+4HQOotFGLxqAXwOzCHsRSRjcQFNOr8lhUt6LeYjf0NSRL04sc4X0dWe8DsCvYKxMYvFG/iOJw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "faye-websocket": "0.11.4", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/database-compat": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.11.tgz", - "integrity": "sha512-itEsHARSsYS95+udF/TtIzNeQ0Uhx4uIna0sk4E0wQJBUnLc/G1X6D7oRljoOuwwCezRLGvWBRyNrugv/esOEw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/database": "1.0.20", - "@firebase/database-types": "1.0.15", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/database-types": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.15.tgz", - "integrity": "sha512-XWHJ0VUJ0k2E9HDMlKxlgy/ZuTa9EvHCGLjaKSUvrQnwhgZuRU5N3yX6SZ+ftf2hTzZmfRkv+b3QRvGg40bKNw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/app-types": "0.9.3", - "@firebase/util": "1.12.1" - } - }, - "node_modules/@firebase/firestore": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.8.0.tgz", - "integrity": "sha512-QSRk+Q1/CaabKyqn3C32KSFiOdZpSqI9rpLK5BHPcooElumOBooPFa6YkDdiT+/KhJtel36LdAacha9BptMj2A==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "@firebase/webchannel-wrapper": "1.0.3", - "@grpc/grpc-js": "~1.9.0", - "@grpc/proto-loader": "^0.7.8", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/firestore-compat": { - "version": "0.3.53", - "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.53.tgz", - "integrity": "sha512-qI3yZL8ljwAYWrTousWYbemay2YZa+udLWugjdjju2KODWtLG94DfO4NALJgPLv8CVGcDHNFXoyQexdRA0Cz8Q==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/firestore": "4.8.0", - "@firebase/firestore-types": "3.0.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/firestore-types": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", - "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/functions": { - "version": "0.12.9", - "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.9.tgz", - "integrity": "sha512-FG95w6vjbUXN84Ehezc2SDjGmGq225UYbHrb/ptkRT7OTuCiQRErOQuyt1jI1tvcDekdNog+anIObihNFz79Lg==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/auth-interop-types": "0.2.4", - "@firebase/component": "0.6.18", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/functions-compat": { - "version": "0.3.26", - "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.26.tgz", - "integrity": "sha512-A798/6ff5LcG2LTWqaGazbFYnjBW8zc65YfID/en83ALmkhu2b0G8ykvQnLtakbV9ajrMYPn7Yc/XcYsZIUsjA==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/functions": "0.12.9", - "@firebase/functions-types": "0.6.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/functions-types": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", - "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/installations": { - "version": "0.6.18", - "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.18.tgz", - "integrity": "sha512-NQ86uGAcvO8nBRwVltRL9QQ4Reidc/3whdAasgeWCPIcrhOKDuNpAALa6eCVryLnK14ua2DqekCOX5uC9XbU/A==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/installations-compat": { - "version": "0.2.18", - "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.18.tgz", - "integrity": "sha512-aLFohRpJO5kKBL/XYL4tN+GdwEB/Q6Vo9eZOM/6Kic7asSUgmSfGPpGUZO1OAaSRGwF4Lqnvi1f/f9VZnKzChw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/installations-types": "0.5.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/installations-types": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", - "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/logger": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", - "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/messaging": { - "version": "0.12.22", - "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.22.tgz", - "integrity": "sha512-GJcrPLc+Hu7nk+XQ70Okt3M1u1eRr2ZvpMbzbc54oTPJZySHcX9ccZGVFcsZbSZ6o1uqumm8Oc7OFkD3Rn1/og==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/messaging-interop-types": "0.2.3", - "@firebase/util": "1.12.1", - "idb": "7.1.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/messaging-compat": { - "version": "0.2.22", - "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.22.tgz", - "integrity": "sha512-5ZHtRnj6YO6f/QPa/KU6gryjmX4Kg33Kn4gRpNU6M1K47Gm8kcQwPkX7erRUYEH1mIWptfvjvXMHWoZaWjkU7A==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/messaging": "0.12.22", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/messaging-interop-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", - "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/performance": { - "version": "0.7.7", - "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.7.tgz", - "integrity": "sha512-JTlTQNZKAd4+Q5sodpw6CN+6NmwbY72av3Lb6wUKTsL7rb3cuBIhQSrslWbVz0SwK3x0ZNcqX24qtRbwKiv+6w==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0", - "web-vitals": "^4.2.4" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/performance-compat": { - "version": "0.2.20", - "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.20.tgz", - "integrity": "sha512-XkFK5NmOKCBuqOKWeRgBUFZZGz9SzdTZp4OqeUg+5nyjapTiZ4XoiiUL8z7mB2q+63rPmBl7msv682J3rcDXIQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/performance": "0.7.7", - "@firebase/performance-types": "0.2.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/performance-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", - "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", - "license": "Apache-2.0" - }, - "node_modules/@firebase/remote-config": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.5.tgz", - "integrity": "sha512-fU0c8HY0vrVHwC+zQ/fpXSqHyDMuuuglV94VF6Yonhz8Fg2J+KOowPGANM0SZkLvVOYpTeWp3ZmM+F6NjwWLnw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/remote-config-compat": { - "version": "0.2.18", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.18.tgz", - "integrity": "sha512-YiETpldhDy7zUrnS8e+3l7cNs0sL7+tVAxvVYU0lu7O+qLHbmdtAxmgY+wJqWdW2c9nDvBFec7QiF58pEUu0qQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/remote-config": "0.6.5", - "@firebase/remote-config-types": "0.4.0", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/remote-config-types": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", - "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@firebase/storage": { - "version": "0.13.14", - "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.14.tgz", - "integrity": "sha512-xTq5ixxORzx+bfqCpsh+o3fxOsGoDjC1nO0Mq2+KsOcny3l7beyBhP/y1u5T6mgsFQwI1j6oAkbT5cWdDBx87g==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/storage-compat": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.24.tgz", - "integrity": "sha512-XHn2tLniiP7BFKJaPZ0P8YQXKiVJX+bMyE2j2YWjYfaddqiJnROJYqSomwW6L3Y+gZAga35ONXUJQju6MB6SOQ==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/storage": "0.13.14", - "@firebase/storage-types": "0.8.3", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" - } - }, - "node_modules/@firebase/storage-types": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", - "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", - "license": "Apache-2.0", - "peerDependencies": { - "@firebase/app-types": "0.x", - "@firebase/util": "1.x" - } - }, - "node_modules/@firebase/util": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.1.tgz", - "integrity": "sha512-zGlBn/9Dnya5ta9bX/fgEoNC3Cp8s6h+uYPYaDieZsFOAdHP/ExzQ/eaDgxD3GOROdPkLKpvKY0iIzr9adle0w==", - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@firebase/webchannel-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", - "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==", - "license": "Apache-2.0", - "peer": true - }, - "node_modules/@gar/promise-retry": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.2.tgz", - "integrity": "sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "retry": "^0.13.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.7.8", - "@types/node": ">=12.12.47" - }, - "engines": { - "node": "^8.13.0 || >=10.10.0" - } - }, - "node_modules/@grpc/proto-loader": { - "version": "0.7.15", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", - "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.2.5", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@grpc/proto-loader/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==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@grpc/proto-loader/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==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@grpc/proto-loader/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@grpc/proto-loader/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==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@grpc/proto-loader/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==", - "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/@grpc/proto-loader/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==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@grpc/proto-loader/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "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/@grpc/proto-loader/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@grpc/proto-loader/node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/@harperfast/extended-iterable": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", - "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", - "dev": true, - "license": "Apache-2.0", - "optional": true - }, - "node_modules/@hono/node-server": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", - "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jasminejs/reporters": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jasminejs/reporters/-/reporters-1.0.0.tgz", - "integrity": "sha512-rM3GG4vx2H1Gp5kYCTr9aKlOEJFd43pzpiMAiy5b1+FUc2ub4e6bS6yCi/WQNDzAa5MVp9++dwcoEtcIfoEnhA==", - "dev": true, - "license": "MIT" - }, - "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==", - "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==", - "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==", - "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==", - "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==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@listr2/prompt-adapter-inquirer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", - "integrity": "sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/type": "^3.0.8" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@inquirer/prompts": ">= 3 < 8", - "listr2": "9.0.5" - } - }, - "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.1.tgz", - "integrity": "sha512-tpfN4kKrrMpQ+If1l8bhmoNkECJi0iOu6AEdrTJvWVC+32sLxTARX5Rsu579mPImRP9YFWfWgeRQ5oav7zApQQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.1.tgz", - "integrity": "sha512-+a2tTfc3rmWhLAolFUWRgJtpSuu+Fw/yjn4rF406NMxhfjbMuiOUTDRvRlMFV+DzyjkwnokisskHbCWkS3Ly5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.1.tgz", - "integrity": "sha512-0EgcE6reYr8InjD7V37EgXcYrloqpxVPINy3ig1MwDSbl6LF/vXTYRH9OE1Ti1D8YZnB35ZH9aTcdfSb5lql2A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.1.tgz", - "integrity": "sha512-aoERa5B6ywXdyFeYGQ1gbQpkMkDbEo45qVoXE5QpIRavqjnyPwjOulMkmkypkmsbJ5z4Wi0TBztON8agCTG0Vg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-x64": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.1.tgz", - "integrity": "sha512-SqNDY1+vpji7bh0sFH5wlWyFTOzjbDOl0/kB5RLLYDAFyd/uw3n7wyrmas3rYPpAW7z18lMOi1yKlTPv967E3g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-win32-arm64": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.1.tgz", - "integrity": "sha512-50v0O1Lt37cwrmR9vWZK5hRW0Aw+KEmxJJ75fge/zIYdvNKB/0bSMSVR5Uc2OV9JhosIUyklOmrEvavwNJ8D6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.1.tgz", - "integrity": "sha512-qwosvPyl+zpUlp3gRb7UcJ3H8S28XHCzkv0Y0EgQToXjQP91ZD67EHSCDmaLjtKhe+GVIW5om1KUpzVLA0l6pg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@napi-rs/nice": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", - "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/nice-android-arm-eabi": "1.1.1", - "@napi-rs/nice-android-arm64": "1.1.1", - "@napi-rs/nice-darwin-arm64": "1.1.1", - "@napi-rs/nice-darwin-x64": "1.1.1", - "@napi-rs/nice-freebsd-x64": "1.1.1", - "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", - "@napi-rs/nice-linux-arm64-gnu": "1.1.1", - "@napi-rs/nice-linux-arm64-musl": "1.1.1", - "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", - "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", - "@napi-rs/nice-linux-s390x-gnu": "1.1.1", - "@napi-rs/nice-linux-x64-gnu": "1.1.1", - "@napi-rs/nice-linux-x64-musl": "1.1.1", - "@napi-rs/nice-openharmony-arm64": "1.1.1", - "@napi-rs/nice-win32-arm64-msvc": "1.1.1", - "@napi-rs/nice-win32-ia32-msvc": "1.1.1", - "@napi-rs/nice-win32-x64-msvc": "1.1.1" - } - }, - "node_modules/@napi-rs/nice-android-arm-eabi": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", - "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-android-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", - "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", - "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", - "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-freebsd-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", - "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", - "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", - "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", - "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", - "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", - "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-s390x-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", - "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", - "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", - "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-openharmony-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", - "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-arm64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", - "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-ia32-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", - "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-x64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", - "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@npmcli/agent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", - "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^11.2.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@npmcli/fs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", - "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/git": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", - "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "ini": "^6.0.0", - "lru-cache": "^11.2.1", - "npm-pick-manifest": "^11.0.1", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/git/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@npmcli/git/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/installed-package-contents": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", - "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^5.0.0", - "npm-normalize-package-bin": "^5.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/node-gyp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", - "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/package-json": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", - "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^7.0.0", - "glob": "^13.0.0", - "hosted-git-info": "^9.0.0", - "json-parse-even-better-errors": "^5.0.0", - "proc-log": "^6.0.0", - "semver": "^7.5.3", - "spdx-expression-parse": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/promise-spawn": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", - "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/promise-spawn/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/redact": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", - "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/run-script": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", - "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "node-gyp": "^12.1.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.113.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.113.0.tgz", - "integrity": "sha512-Tp3XmgxwNQ9pEN9vxgJBAqdRamHibi76iowQ38O2I4PMpcvNRQNVsU2n1x1nv9yh0XoTrGFzf7cZSGxmixxrhA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz", - "integrity": "sha512-vRq9f4NzvbdZavhQbjkJBx7rRebDKYR9zHfO/Wg486+I7bSecdUapzCm5cyXoK+LHokTxgSq7A5baAXUZkIz0w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.4.tgz", - "integrity": "sha512-kFgEvkWLqt3YCgKB5re9RlIrx9bRsvyVUnaTakEpOPuLGzLpLapYxE9BufJNvPg8GjT6mB1alN4yN1NjzoeM8Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.4.tgz", - "integrity": "sha512-JXmaOJGsL/+rsmMfutcDjxWM2fTaVgCHGoXS7nE8Z3c9NAYjGqHvXrAhMUZvMpHS/k7Mg+X7n/MVKb7NYWKKww==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.4.tgz", - "integrity": "sha512-ep3Catd6sPnHTM0P4hNEvIv5arnDvk01PfyJIJ+J3wVCG1eEaPo09tvFqdtcaTrkwQy0VWR24uz+cb4IsK53Qw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.4.tgz", - "integrity": "sha512-LwA5ayKIpnsgXJEwWc3h8wPiS33NMIHd9BhsV92T8VetVAbGe2qXlJwNVDGHN5cOQ22R9uYvbrQir2AB+ntT2w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.4.tgz", - "integrity": "sha512-AC1WsGdlV1MtGay/OQ4J9T7GRadVnpYRzTcygV1hKnypbYN20Yh4t6O1Sa2qRBMqv1etulUknqXjc3CTIsBu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.4.tgz", - "integrity": "sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.4.tgz", - "integrity": "sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.4.tgz", - "integrity": "sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.4.tgz", - "integrity": "sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.4.tgz", - "integrity": "sha512-wz7ohsKCAIWy91blZ/1FlpPdqrsm1xpcEOQVveWoL6+aSPKL4VUcoYmmzuLTssyZxRpEwzuIxL/GDsvpjaBtOw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.4.tgz", - "integrity": "sha512-cfiMrfuWCIgsFmcVG0IPuO6qTRHvF7NuG3wngX1RZzc6dU8FuBFb+J3MIR5WrdTNozlumfgL4cvz+R4ozBCvsQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.4.tgz", - "integrity": "sha512-p6UeR9y7ht82AH57qwGuFYn69S6CZ7LLKdCKy/8T3zS9VTrJei2/CGsTUV45Da4Z9Rbhc7G4gyWQ/Ioamqn09g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.4.tgz", - "integrity": "sha512-1BrrmTu0TWfOP1riA8uakjFc9bpIUGzVKETsOtzY39pPga8zELGDl8eu1Dx7/gjM5CAz14UknsUMpBO8L+YntQ==", - "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/@schematics/angular": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.1.tgz", - "integrity": "sha512-DjrHRMoILhbZ6tc7aNZWuHA1wCm1iU/JN1TxAwNEyIBgyU3Fx8Z5baK4w0TCpOIPt0RLWVgP2L7kka9aXWCUFA==", - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.1", - "@angular-devkit/schematics": "21.2.1", - "jsonc-parser": "3.3.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@sigstore/bundle": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", - "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@sigstore/core": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.1.0.tgz", - "integrity": "sha512-o5cw1QYhNQ9IroioJxpzexmPjfCe7gzafd2RY3qnMpxr4ZEja+Jad/U8sgFpaue6bOaF+z7RVkyKVV44FN+N8A==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@sigstore/protobuf-specs": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz", - "integrity": "sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/sign": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.0.tgz", - "integrity": "sha512-Vx1RmLxLGnSUqx/o5/VsCjkuN5L7y+vxEEwawvc7u+6WtX2W4GNa7b9HEjmcRWohw/d6BpATXmvOwc78m+Swdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", - "@sigstore/protobuf-specs": "^0.5.0", - "make-fetch-happen": "^15.0.3", - "proc-log": "^6.1.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@sigstore/tuf": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.1.tgz", - "integrity": "sha512-OPZBg8y5Vc9yZjmWCHrlWPMBqW5yd8+wFNl+thMdtcWz3vjVSoJQutF8YkrzI0SLGnkuFof4HSsWUhXrf219Lw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0", - "tuf-js": "^4.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@sigstore/verify": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", - "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "license": "MIT", - "optional": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "license": "MIT", - "optional": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "license": "MIT", - "optional": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "license": "MIT", - "optional": true - }, - "node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", - "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@tufjs/models": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", - "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^10.1.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "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==", - "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==", - "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==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jasmine": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-6.0.0.tgz", - "integrity": "sha512-18lgGsLmEh3VJk9eZ5wAjTISxdqzl6YOwu8UdMpolajN57QOCNbl+AbHUd+Yu9ItrsFdB+c8LSZSGNg8nHaguw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "8.56.1", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitejs/plugin-basic-ssl": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.4.tgz", - "integrity": "sha512-HXciTXN/sDBYWgeAD4V4s0DN0g72x5mlxQhHxtYu3Tt8BLa6MzcJZUyDVFCdtjNs3bfENVHVzOsmooTVuNgAAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "peerDependencies": { - "vite": "^6.0.0 || ^7.0.0" - } - }, - "node_modules/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "devOptional": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "license": "MIT", - "optional": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "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/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/algoliasearch": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", - "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.14.1", - "@algolia/client-abtesting": "5.48.1", - "@algolia/client-analytics": "5.48.1", - "@algolia/client-common": "5.48.1", - "@algolia/client-insights": "5.48.1", - "@algolia/client-personalization": "5.48.1", - "@algolia/client-query-suggestions": "5.48.1", - "@algolia/client-search": "5.48.1", - "@algolia/ingestion": "1.48.1", - "@algolia/monitoring": "1.48.1", - "@algolia/recommend": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "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==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "license": "MIT", - "optional": true - }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "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/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/beasties": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.1.tgz", - "integrity": "sha512-2Imdcw3LznDuxAbJM26RHniOLAzE6WgrK8OuvVXCQtNBS8rsnD9zsSEa3fHl4hHpUY7BYTlrpvtPVbvu9G6neg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "css-select": "^6.0.0", - "css-what": "^7.0.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "htmlparser2": "^10.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3", - "postcss-safe-parser": "^7.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/bootstrap": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", - "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT", - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, - "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/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "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/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cacache": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", - "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^5.0.0", - "fs-minipass": "^3.0.0", - "glob": "^13.0.0", - "lru-cache": "^11.1.0", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^13.0.0", - "unique-filename": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/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==", - "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==", - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/connect/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/core-js": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "license": "MIT", - "optional": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-select": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true, - "license": "MIT" - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "license": "BSD-3-Clause", - "optional": true, - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", - "license": "ISC" - }, - "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==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/engine.io": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", - "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/engine.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", - "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^9.1.2", - "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", - "esquery": "^1.7.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", - "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@types/esrecurse": "^4.3.1", - "@types/estree": "^1.0.8", - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/espree": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", - "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.16.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^5.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", - "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "10.1.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.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==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/firebase": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.10.0.tgz", - "integrity": "sha512-nKBXoDzF0DrXTBQJlZa+sbC5By99ysYU1D6PkMRYknm0nCW7rJly47q492Ht7Ndz5MeYSBuboKuhS1e6mFC03w==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@firebase/ai": "1.4.1", - "@firebase/analytics": "0.10.17", - "@firebase/analytics-compat": "0.2.23", - "@firebase/app": "0.13.2", - "@firebase/app-check": "0.10.1", - "@firebase/app-check-compat": "0.3.26", - "@firebase/app-compat": "0.4.2", - "@firebase/app-types": "0.9.3", - "@firebase/auth": "1.10.8", - "@firebase/auth-compat": "0.5.28", - "@firebase/data-connect": "0.3.10", - "@firebase/database": "1.0.20", - "@firebase/database-compat": "2.0.11", - "@firebase/firestore": "4.8.0", - "@firebase/firestore-compat": "0.3.53", - "@firebase/functions": "0.12.9", - "@firebase/functions-compat": "0.3.26", - "@firebase/installations": "0.6.18", - "@firebase/installations-compat": "0.2.18", - "@firebase/messaging": "0.12.22", - "@firebase/messaging-compat": "0.2.22", - "@firebase/performance": "0.7.7", - "@firebase/performance-compat": "0.2.20", - "@firebase/remote-config": "0.6.5", - "@firebase/remote-config-compat": "0.2.18", - "@firebase/storage": "0.13.14", - "@firebase/storage-compat": "0.3.24", - "@firebase/util": "1.12.1" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/font-awesome": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", - "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==", - "license": "(OFL-1.1 AND MIT)", - "engines": { - "node": ">=0.10.3" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fuzzy": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz", - "integrity": "sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==", - "dev": true, - "engines": { - "node": ">= 0.6.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==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", - "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/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.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "license": "ISC" - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/ignore-walk": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", - "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minimatch": "^10.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", - "dev": true, - "license": "MIT" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-reports": { - "version": "3.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/jasmine": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-6.1.0.tgz", - "integrity": "sha512-WPphPqEMY0uBRMjuhRHoVoxQNvJuxIMqz0yIcJ3k3oYxBedeGoH60/NXNgasxnx2FvfXrq5/r+2wssJ7WE8ABw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jasminejs/reporters": "^1.0.0", - "glob": "^10.2.2 || ^11.0.3 || ^12.0.0 || ^13.0.0", - "jasmine-core": "~6.1.0" - }, - "bin": { - "jasmine": "bin/jasmine.js" - } - }, - "node_modules/jasmine-core": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-6.1.0.tgz", - "integrity": "sha512-p/tjBw58O6vxKIWMlrU+yys8lqR3+l3UrqwNTT7wpj+dQ7N4etQekFM8joI+cWzPDYqZf54kN+hLC1+s5TvZvg==", - "dev": true, - "license": "MIT" - }, - "node_modules/jasmine-spec-reporter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/jasmine-spec-reporter/-/jasmine-spec-reporter-7.0.0.tgz", - "integrity": "sha512-OtC7JRasiTcjsaCBPtMO0Tl8glCejM4J4/dNuOJdA8lBjz4PmWjYQ6pzb0uzpBNAWJMDudYuj9OdXJWqM2QTJg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "colors": "1.4.0" - } - }, - "node_modules/jose": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz", - "integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "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==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", - "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "which": "^1.2.1" - } - }, - "node_modules/karma-chrome-launcher/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/karma-coverage/node_modules/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/karma-coverage/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma-coverage/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma-coverage/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "jasmine-core": "^4.1.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" - } - }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.2.0.tgz", - "integrity": "sha512-J0laEC43Oy2RdR5V5R3bqmdo7yRIYySq6XHKbA+e5iSAgLjhR1oICLGeSREPlJXpeyNcdJf3J17YcdhD0mRssQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0 || ^6.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" - } - }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma-junit-reporter": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/karma-junit-reporter/-/karma-junit-reporter-2.0.1.tgz", - "integrity": "sha512-VtcGfE0JE4OE1wn0LK8xxDKaTP7slN8DO3I+4xg6gAi1IoAHAXOJ1V9G/y45Xg6sxdxPOR3THCFtDlAfBo9Afw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-is-absolute": "^1.0.0", - "xmlbuilder": "12.0.0" - }, - "engines": { - "node": ">= 8" - }, - "peerDependencies": { - "karma": ">=0.9" - } - }, - "node_modules/karma/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/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/karma/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/karma/node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/karma/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/karma/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/karma/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/karma/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "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": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/karma/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/karma/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/karma/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/karma/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/karma/node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/karma/node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/karma/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/karma/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/karma/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/karma/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/lmdb": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.1.tgz", - "integrity": "sha512-NYHA0MRPjvNX+vSw8Xxg6FLKxzAG+e7Pt8RqAQA/EehzHVXq9SxDqJIN3JL1hK0dweb884y8kIh6rkWvPyg9Wg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@harperfast/extended-iterable": "^1.0.3", - "msgpackr": "^1.11.2", - "node-addon-api": "^6.1.0", - "node-gyp-build-optional-packages": "5.2.2", - "ordered-binary": "^1.5.3", - "weak-lru-cache": "^1.2.2" - }, - "bin": { - "download-lmdb-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.5.1", - "@lmdb/lmdb-darwin-x64": "3.5.1", - "@lmdb/lmdb-linux-arm": "3.5.1", - "@lmdb/lmdb-linux-arm64": "3.5.1", - "@lmdb/lmdb-linux-x64": "3.5.1", - "@lmdb/lmdb-win32-arm64": "3.5.1", - "@lmdb/lmdb-win32-x64": "3.5.1" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "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-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "license": "ISC", - "optional": true - }, - "node_modules/make-fetch-happen": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.4.tgz", - "integrity": "sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/agent": "^4.0.0", - "cacache": "^20.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^6.0.0", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.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/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", - "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^2.0.0", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - }, - "optionalDependencies": { - "iconv-lite": "^0.7.2" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-sized": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", - "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/msgpackr": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz", - "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==", - "dev": true, - "license": "MIT", - "optional": true, - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-gyp": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", - "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "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==", - "license": "MIT" - }, - "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-bundled": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", - "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-install-checks": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", - "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", - "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-package-arg": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", - "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-packlist": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", - "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", - "dev": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^8.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-pick-manifest": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", - "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "npm-package-arg": "^13.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-registry-fetch": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", - "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^4.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^15.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^13.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ora": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", - "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ordered-binary": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", - "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pacote": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz", - "integrity": "sha512-O0EDXi85LF4AzdjG74GUwEArhdvawi/YOHcsW6IijKNj7wm8IvEWNF5GnfuxNpQ/ZpO3L37+v8hqdVh8GgWYhg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^7.0.0", - "@npmcli/installed-package-contents": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "@npmcli/run-script": "^10.0.0", - "cacache": "^20.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^13.0.0", - "npm-packlist": "^10.0.1", - "npm-pick-manifest": "^11.0.1", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^4.0.0", - "ssri": "^13.0.0", - "tar": "^7.4.3" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-html-rewriting-stream": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz", - "integrity": "sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0", - "parse5": "^8.0.0", - "parse5-sax-parser": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-html-rewriting-stream/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/parse5-sax-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", - "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse5": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/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/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/piscina": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.4.tgz", - "integrity": "sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.x" - }, - "optionalDependencies": { - "@napi-rs/nice": "^1.0.4" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "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/postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss-safe-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/promise-retry/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.9" - } - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/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/rimraf/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "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": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/rolldown": { - "version": "1.0.0-rc.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.4.tgz", - "integrity": "sha512-V2tPDUrY3WSevrvU2E41ijZlpF+5PbZu4giH+VpNraaadsJGHa4fR6IFwsocVwEXDoAdIv5qgPPxgrvKAOIPtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.113.0", - "@rolldown/pluginutils": "1.0.0-rc.4" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.4", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.4", - "@rolldown/binding-darwin-x64": "1.0.0-rc.4", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.4", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.4", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.4", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.4", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.4", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.4", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.4", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.4", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.4" - } - }, - "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/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/rxfire": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/rxfire/-/rxfire-6.1.0.tgz", - "integrity": "sha512-NezdjeY32VZcCuGO0bbb8H8seBsJSCaWdUwGsHNzUcAOHR0VGpzgPtzjuuLXr8R/iemkqSzbx/ioS7VwV43ynA==", - "license": "Apache-2.0", - "peerDependencies": { - "firebase": "^9.0.0 || ^10.0.0 || ^11.0.0", - "rxjs": "^6.0.0 || ^7.0.0" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sass": { - "version": "1.97.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", - "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/sass/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/sass/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/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/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sigstore": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", - "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", - "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.18.3" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io/node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socket.io/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/ssri": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", - "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stdin-discarder": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", - "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/tar": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", - "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "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/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-node": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz", - "integrity": "sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==", - "license": "MIT", - "optional": true, - "dependencies": { - "@cspotcode/source-map-support": "0.7.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.0", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tuf-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", - "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "4.1.0", - "debug": "^4.4.3", - "make-fetch-happen": "^15.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ua-parser-js": { - "version": "0.7.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", - "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/unique-filename": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", - "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", - "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/unique-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", - "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.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==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/uri-js/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/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "license": "MIT", - "optional": true - }, - "node_modules/validate-npm-package-name": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "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/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/weak-lru-cache": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", - "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/web-vitals": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", - "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", - "license": "Apache-2.0" - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/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/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "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/xhr2": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/xhr2/-/xhr2-0.2.1.tgz", - "integrity": "sha512-sID0rrVCqkVNUn8t6xuv9+6FViXjUVXq8H5rWOH2rz9fDNQEd4g0EA2XlcEdJXRz5BMEn4O1pJFdT+z4YHhoWw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/xmlbuilder": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-12.0.0.tgz", - "integrity": "sha512-lMo8DJ8u6JRWp0/Y4XLa/atVDr75H9litKlb2E5j3V3MesoL50EBgZDWoLT3F/LztVnG67GjPXLZpqcky/UMnQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" - }, - "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - }, - "node_modules/zone.js": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.1.tgz", - "integrity": "sha512-dpvY17vxYIW3+bNrP0ClUlaiY0CiIRK3tnoLaGoQsQcY9/I/NpzIWQ7tQNhbV7LacQMpCII6wVzuL3tuWOyfuA==", - "license": "MIT" - } - } -} diff --git a/samples/angular/package.json b/samples/angular/package.json deleted file mode 100644 index cbb28ecc..00000000 --- a/samples/angular/package.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "name": "keeptrack-angularwebapp", - "version": "0.1.0", - "scripts": { - "ng": "ng", - "start": "ng serve --configuration=dev", - "build": "ng build", - "watch": "ng build --watch --configuration dev", - "test": "ng test --configuration dev", - "test:ci": "ng test --no-watch --configuration=dev --code-coverage --browsers ChromeHeadless", - "lint": "ng lint", - "e2e": "ng e2e" - }, - "private": true, - "dependencies": { - "@angular/animations": "^21.2.1", - "@angular/common": "^21.2.1", - "@angular/compiler": "^21.2.1", - "@angular/core": "^21.2.1", - "@angular/fire": "^21.0.0-rc.0", - "@angular/forms": "^21.2.1", - "@angular/localize": "^21.2.1", - "@angular/platform-browser": "^21.2.1", - "@angular/platform-browser-dynamic": "^21.2.1", - "@angular/platform-server": "^21.2.1", - "@angular/router": "^21.2.1", - "@popperjs/core": "~2.11.8", - "bootstrap": "~5.3.2", - "core-js": "^3.48.0", - "font-awesome": "^4.7.0", - "rxjs": "~7.8.2", - "tslib": "^2.8.1", - "zone.js": "~0.16.1" - }, - "devDependencies": { - "@angular-devkit/architect": "^0.2102.1", - "@angular-eslint/builder": "^21.3.0", - "@angular-eslint/eslint-plugin": "^21.3.0", - "@angular-eslint/eslint-plugin-template": "^21.3.0", - "@angular-eslint/schematics": "^21.3.0", - "@angular-eslint/template-parser": "^21.3.0", - "@angular/build": "^21.2.1", - "@angular/cli": "^21.2.1", - "@angular/compiler-cli": "^21.2.1", - "@angular/language-service": "^21.2.1", - "@types/jasmine": "~6.0.0", - "@types/node": "^22.13.14", - "acorn": "~8.16.0", - "eslint": "^10.0.3", - "fuzzy": "~0.1.3", - "glob": "^13.0.6", - "jasmine": "~6.1.0", - "jasmine-core": "~6.1.0", - "jasmine-spec-reporter": "~7.0.0", - "karma": "~6.4.4", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.1", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.2.0", - "karma-junit-reporter": "~2.0.1", - "typescript": "~5.9.3" - }, - "optionalDependencies": { - "ts-node": "~10.7.0" - } -} diff --git a/samples/angular/src/app/app.component.html b/samples/angular/src/app/app.component.html deleted file mode 100644 index 8a51c496..00000000 --- a/samples/angular/src/app/app.component.html +++ /dev/null @@ -1,6 +0,0 @@ - - -
- -
- diff --git a/samples/angular/src/app/app.component.ts b/samples/angular/src/app/app.component.ts deleted file mode 100644 index dcbdb596..00000000 --- a/samples/angular/src/app/app.component.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; -import { HeaderComponent } from './layout/header/header.component'; - -@Component({ - selector: 'app-root', - imports: [ - RouterOutlet, - HeaderComponent - ], - templateUrl: './app.component.html' -}) -export class AppComponent { - title = 'app'; -} diff --git a/samples/angular/src/app/app.routes.ts b/samples/angular/src/app/app.routes.ts deleted file mode 100644 index 438e2070..00000000 --- a/samples/angular/src/app/app.routes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Routes } from '@angular/router'; -import { AuthGuard } from '@angular/fire/auth-guard'; - -import { HomeComponent } from './home/home.component'; -import { BookComponent } from './inventory/book/book.component'; -import { CarComponent } from './inventory/car/car.component'; -import { MovieComponent } from './inventory/movie/movie.component'; -import { TvShowComponent } from './inventory/tv-show/tv-show.component'; -import { VideoGameComponent } from './inventory/video-game/video-game.component'; -import { LoginComponent } from './user/login/login.component'; - -export const routes: Routes = [ - { path: '', component: HomeComponent, pathMatch: 'full' }, - { path: 'login', component: LoginComponent }, - { path: 'movies', component: MovieComponent, canActivate: [AuthGuard] }, - { path: 'books', component: BookComponent, canActivate: [AuthGuard] }, - { path: 'cars', component: CarComponent, canActivate: [AuthGuard] }, - { path: 'tv-shows', component: TvShowComponent, canActivate: [AuthGuard] }, - { path: 'video-games', component: VideoGameComponent, canActivate: [AuthGuard] } -]; diff --git a/samples/angular/src/app/backend/backend.module.ts b/samples/angular/src/app/backend/backend.module.ts deleted file mode 100644 index caa787d1..00000000 --- a/samples/angular/src/app/backend/backend.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MovieService } from './services/movie.service'; -import { CarHistoryService } from './services/car-history.service'; - -@NgModule({ - declarations: [], - imports: [ - CommonModule - ], - providers: [ - CarHistoryService, - MovieService - ] -}) -export class BackendModule { } diff --git a/samples/angular/src/app/backend/services/book.service.spec.ts b/samples/angular/src/app/backend/services/book.service.spec.ts deleted file mode 100644 index 1312587e..00000000 --- a/samples/angular/src/app/backend/services/book.service.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; - -import { BookService } from './book.service'; -import { Book } from '../types/book'; -import { environment } from 'src/environments/environment.dev'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('BookService', () => { - let bookService: BookService; - let http: HttpTestingController; - - beforeEach(() => TestBed.configureTestingModule({ - imports: [], - providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -})); - - beforeEach(() => { - http = TestBed.inject(HttpTestingController); - bookService = TestBed.inject(BookService); - }); - - afterAll(() => http.verify()); - - it('should list', () => { - // fake response - const hardcodedBooks = [{ title: 'The fellowship of the Ring' }, { title: 'The two Towers' }] as Array; - - let actualBooks: Array = []; - bookService.list().subscribe((books: Array) => actualBooks = books); - - http.expectOne(`${environment.keepTrackApiUrl}/api/books?search=&page=0&pageSize=50`) - .flush(hardcodedBooks); - - expect(actualBooks).toEqual(hardcodedBooks, 'The `list` method should return an array of Book wrapped in an Observable'); - }); -}); diff --git a/samples/angular/src/app/backend/services/book.service.ts b/samples/angular/src/app/backend/services/book.service.ts deleted file mode 100644 index e4ee8478..00000000 --- a/samples/angular/src/app/backend/services/book.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { Book } from '../types/book'; -import { DataService } from './data.interface'; - -@Injectable({ - providedIn: 'root' -}) -export class BookService implements DataService { - private httpClient = inject(HttpClient); - - get(id: string): Observable { - return this.httpClient.get(`${environment.keepTrackApiUrl}/api/books/${id}`); - } - - list(search?: string, currentPage?: number, pageSize?: number): Observable> { - return this.httpClient.get>(`${environment.keepTrackApiUrl}/api/books?search=${search ?? ''}&page=${currentPage ?? 0}&pageSize=${pageSize ?? 50}`); - } - - create(input: Book): Observable { - return this.httpClient.post(`${environment.keepTrackApiUrl}/api/books`, input); - } - - update(input: Book): Observable { - delete input.isEditable; - if (!input.finishedAt) { - delete input.finishedAt; - } - return this.httpClient.put(`${environment.keepTrackApiUrl}/api/books/${input.id}`, input); - } - - delete(input: Book): Observable { - return this.httpClient.delete(`${environment.keepTrackApiUrl}/api/books/${input.id}`); - } -} diff --git a/samples/angular/src/app/backend/services/car-history.service.spec.ts b/samples/angular/src/app/backend/services/car-history.service.spec.ts deleted file mode 100644 index 1f1e53ed..00000000 --- a/samples/angular/src/app/backend/services/car-history.service.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { CarHistoryService } from './car-history.service'; - -describe('CarHistoryService', () => { - beforeEach(() => TestBed.configureTestingModule({})); - - it('should be created', () => { - const service: CarHistoryService = TestBed.inject(CarHistoryService); - expect(service).toBeTruthy(); - }); -}); diff --git a/samples/angular/src/app/backend/services/car-history.service.ts b/samples/angular/src/app/backend/services/car-history.service.ts deleted file mode 100644 index efb1657c..00000000 --- a/samples/angular/src/app/backend/services/car-history.service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root' -}) -export class CarHistoryService { -} diff --git a/samples/angular/src/app/backend/services/data.interface.ts b/samples/angular/src/app/backend/services/data.interface.ts deleted file mode 100644 index 399a0589..00000000 --- a/samples/angular/src/app/backend/services/data.interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Observable } from "rxjs"; - -export interface DataService { - get(id: string): Observable; - list(search?: string, currentPage?: number, pageSize?: number, filter?: T): Observable>; - create(input: T): Observable; - update(input: T): Observable; - delete(input: T): Observable; -} diff --git a/samples/angular/src/app/backend/services/movie.service.spec.ts b/samples/angular/src/app/backend/services/movie.service.spec.ts deleted file mode 100644 index 73e63c11..00000000 --- a/samples/angular/src/app/backend/services/movie.service.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; - -import { environment } from 'src/environments/environment.dev'; -import { MovieService } from './movie.service'; -import { Movie } from '../types/movie'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('MovieService', () => { - let movieService: MovieService; - let http: HttpTestingController; - - beforeEach(() => TestBed.configureTestingModule({ - imports: [], - providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -})); - - beforeEach(() => { - http = TestBed.inject(HttpTestingController); - movieService = TestBed.inject(MovieService); - }); - - afterAll(() => http.verify()); - - it('should list', () => { - // fake response - const hardcodedMovies = [{ title: 'Terminator 1' }, { title: 'Terminator 2' }] as Array; - - let actualMovies: Array = []; - movieService.list().subscribe((movies: Array) => actualMovies = movies); - - http.expectOne(`${environment.keepTrackApiUrl}/api/movies?search=&page=0&pageSize=50`) - .flush(hardcodedMovies); - - expect(actualMovies).toEqual(hardcodedMovies, 'The `list` method should return an array of Movie wrapped in an Observable'); - }); -}); diff --git a/samples/angular/src/app/backend/services/movie.service.ts b/samples/angular/src/app/backend/services/movie.service.ts deleted file mode 100644 index e5d07315..00000000 --- a/samples/angular/src/app/backend/services/movie.service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { DataService } from './data.interface'; -import { Movie } from '../types/movie'; - -@Injectable({ - providedIn: 'root' -}) -export class MovieService implements DataService { - private httpClient = inject(HttpClient); - - get(id: string): Observable { - return this.httpClient.get(`${environment.keepTrackApiUrl}/api/movies/${id}`); - } - - list(search?: string, currentPage?: number, pageSize?: number): Observable> { - return this.httpClient.get>(`${environment.keepTrackApiUrl}/api/movies?search=${search ?? ''}&page=${currentPage ?? 0}&pageSize=${pageSize ?? 50}`); - } - - create(input: Movie): Observable { - return this.httpClient.post(`${environment.keepTrackApiUrl}/api/movies`, input); - } - - update(input: Movie): Observable { - delete input.isEditable; - if (!input.year) { - delete input.year; - } - - return this.httpClient.put(`${environment.keepTrackApiUrl}/api/movies/${input.id}`, input); - } - - delete(input: Movie): Observable { - return this.httpClient.delete(`${environment.keepTrackApiUrl}/api/movies/${input.id}`); - } -} diff --git a/samples/angular/src/app/backend/services/tv-show.service.spec.ts b/samples/angular/src/app/backend/services/tv-show.service.spec.ts deleted file mode 100644 index ccdec45c..00000000 --- a/samples/angular/src/app/backend/services/tv-show.service.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { environment } from 'src/environments/environment.dev'; -import { TvShow } from '../types/tv-show'; -import { TvShowService } from './tv-show.service'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('TvShowService', () => { - let service: TvShowService; - let http: HttpTestingController; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -}); - http = TestBed.inject(HttpTestingController); - service = TestBed.inject(TvShowService); - }); - - afterAll(() => http.verify()); - - it('should list', () => { - // fake response - const fake = [{ title: 'Friends' }, { title: 'ER' }] as Array; - - let actual: Array = []; - service.list().subscribe((movies: Array) => actual = movies); - - http.expectOne(`${environment.keepTrackApiUrl}/api/tv-shows?search=&page=0&pageSize=50`) - .flush(fake); - - expect(actual).toEqual(fake, 'The `list` method should return an array of TV Shows wrapped in an Observable'); - }); - -}); diff --git a/samples/angular/src/app/backend/services/tv-show.service.ts b/samples/angular/src/app/backend/services/tv-show.service.ts deleted file mode 100644 index d731edb2..00000000 --- a/samples/angular/src/app/backend/services/tv-show.service.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { TvShow } from '../types/tv-show'; -import { DataService } from './data.interface'; - -@Injectable({ - providedIn: 'root' -}) -export class TvShowService implements DataService { - private httpClient = inject(HttpClient); - - get(id: string): Observable { - return this.httpClient.get(`${environment.keepTrackApiUrl}/api/tv-shows/${id}`); - } - - list(search?: string, currentPage?: number, pageSize?: number): Observable> { - return this.httpClient.get>(`${environment.keepTrackApiUrl}/api/tv-shows?search=${search ?? ''}&page=${currentPage ?? 0}&pageSize=${pageSize ?? 50}`); - } - - create(input: TvShow): Observable { - return this.httpClient.post(`${environment.keepTrackApiUrl}/api/tv-shows`, input); - } - - update(input: TvShow): Observable { - delete input.isEditable; - return this.httpClient.put(`${environment.keepTrackApiUrl}/api/tv-shows/${input.id}`, input); - } - - delete(input: TvShow): Observable { - return this.httpClient.delete(`${environment.keepTrackApiUrl}/api/tv-shows/${input.id}`); - } -} diff --git a/samples/angular/src/app/backend/services/video-game.service.spec.ts b/samples/angular/src/app/backend/services/video-game.service.spec.ts deleted file mode 100644 index 7ff50f0c..00000000 --- a/samples/angular/src/app/backend/services/video-game.service.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { VideoGameService } from './video-game.service'; -import { VideoGame } from '../types/video-game'; -import { environment } from 'src/environments/environment.dev'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('VideoGameService', () => { - let service: VideoGameService; - let http: HttpTestingController; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [], - providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -}); - http = TestBed.inject(HttpTestingController); - service = TestBed.inject(VideoGameService); - }); - - afterAll(() => http.verify()); - - it('should list', () => { - // fake response - const fake = [{ title: 'Final Fantasy VII' }, { title: 'Resident Evil' }] as Array; - - let actual: Array = []; - service.list().subscribe((movies: Array) => actual = movies); - - http.expectOne(`${environment.keepTrackApiUrl}/api/video-games?search=&platform=&state=&page=0&pageSize=50`) - .flush(fake); - - expect(actual).toEqual(fake, 'The `list` method should return an array of TV Shows wrapped in an Observable'); - }); -}); diff --git a/samples/angular/src/app/backend/services/video-game.service.ts b/samples/angular/src/app/backend/services/video-game.service.ts deleted file mode 100644 index 01df6aae..00000000 --- a/samples/angular/src/app/backend/services/video-game.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { VideoGame } from '../types/video-game'; -import { DataService } from './data.interface'; - -@Injectable({ - providedIn: 'root' -}) -export class VideoGameService implements DataService { - private httpClient = inject(HttpClient); - - get(id: string): Observable { - return this.httpClient.get(`${environment.keepTrackApiUrl}/api/video-games/${id}`); - } - - list(search?: string, currentPage?: number, pageSize?: number, filter?: VideoGame): Observable> { - return this.httpClient.get>(`${environment.keepTrackApiUrl}/api/video-games?search=${search ?? ''}&platform=${filter?.platform ?? ''}&state=${filter?.state ?? ''}&page=${currentPage ?? 0}&pageSize=${pageSize ?? 50}`); - } - - create(input: VideoGame): Observable { - return this.httpClient.post(`${environment.keepTrackApiUrl}/api/video-games`, input); - } - - update(input: VideoGame): Observable { - delete input.isEditable; - if (!input.finishedAt) { - delete input.finishedAt; - } - if (!input.releasedAt) { - delete input.releasedAt; - } - return this.httpClient.put(`${environment.keepTrackApiUrl}/api/video-games/${input.id}`, input); - } - - delete(input: VideoGame): Observable { - return this.httpClient.delete(`${environment.keepTrackApiUrl}/api/video-games/${input.id}`); - } -} diff --git a/samples/angular/src/app/backend/types/backend-data.d.ts b/samples/angular/src/app/backend/types/backend-data.d.ts deleted file mode 100644 index 54ce53d7..00000000 --- a/samples/angular/src/app/backend/types/backend-data.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface BackendData { - id?: string; - isEditable?: boolean; -} diff --git a/samples/angular/src/app/backend/types/book.d.ts b/samples/angular/src/app/backend/types/book.d.ts deleted file mode 100644 index 8849e68f..00000000 --- a/samples/angular/src/app/backend/types/book.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BackendData } from "./backend-data"; - -export interface Book extends BackendData { - id?: string; - title?: string; - author?: string; - series?: string; - finishedAt?: Date; - isEditable?: boolean; -} diff --git a/samples/angular/src/app/backend/types/car-history.d.ts b/samples/angular/src/app/backend/types/car-history.d.ts deleted file mode 100644 index 28e2c670..00000000 --- a/samples/angular/src/app/backend/types/car-history.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BackendData } from "./backend-data"; - -export interface CarHistory extends BackendData { - id?: string; - carId?: string; - historyDate?: Date; - mileage?: number; - action?: string; - city?: string; - longitude?: number; - latitude?: number; - fuelCategory?: string; - fuelVolume?: number; - fuelUnitPrice?: number; - amount?: number; - isFullTank?: boolean; - deltaMileage?: number; - lastRefuelHistoryId?: string; - stationBrandName?: string; -} diff --git a/samples/angular/src/app/backend/types/car.d.ts b/samples/angular/src/app/backend/types/car.d.ts deleted file mode 100644 index 0392e2e2..00000000 --- a/samples/angular/src/app/backend/types/car.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { BackendData } from "./backend-data"; - -export interface Car extends BackendData { - id?: string; - name?: string; -} diff --git a/samples/angular/src/app/backend/types/movie.d.ts b/samples/angular/src/app/backend/types/movie.d.ts deleted file mode 100644 index b179aaa1..00000000 --- a/samples/angular/src/app/backend/types/movie.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BackendData } from "./backend-data"; - -export interface Movie extends BackendData { - id?: string; - title?: string; - year?: number; - imdbPageId?: string; - allocineId?: string; - isEditable?: boolean; -} diff --git a/samples/angular/src/app/backend/types/tv-show.d.ts b/samples/angular/src/app/backend/types/tv-show.d.ts deleted file mode 100644 index 512d92c1..00000000 --- a/samples/angular/src/app/backend/types/tv-show.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BackendData } from "./backend-data"; - -export interface TvShow extends BackendData { - id?: string; - title?: string; - isEditable?: boolean; -} diff --git a/samples/angular/src/app/backend/types/video-game.d.ts b/samples/angular/src/app/backend/types/video-game.d.ts deleted file mode 100644 index 800fcbe1..00000000 --- a/samples/angular/src/app/backend/types/video-game.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { BackendData } from "./backend-data"; - -export interface VideoGame extends BackendData { - id?: string; - title?: string; - platform?: string; - releasedAt?: Date; - state?: string; - finishedAt?: Date; - isEditable?: boolean; -} diff --git a/samples/angular/src/app/home/home.component.html b/samples/angular/src/app/home/home.component.html deleted file mode 100644 index 59ba4617..00000000 --- a/samples/angular/src/app/home/home.component.html +++ /dev/null @@ -1,9 +0,0 @@ -

Welcome!

-

Welcome to your application to keep track of things. So far, we can manage:

-
    -
  • Books
  • -
  • Cars
  • -
  • Movies
  • -
  • Video games
  • -
  • TV shows
  • -
diff --git a/samples/angular/src/app/home/home.component.ts b/samples/angular/src/app/home/home.component.ts deleted file mode 100644 index 4d02ae5d..00000000 --- a/samples/angular/src/app/home/home.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; -import { FormsModule } from "@angular/forms"; - -@Component({ - selector: 'app-home', - imports: [FormsModule], - templateUrl: './home.component.html' -}) -export class HomeComponent { -} diff --git a/samples/angular/src/app/interceptors/auth.interceptor.ts b/samples/angular/src/app/interceptors/auth.interceptor.ts deleted file mode 100644 index 5c7f6c35..00000000 --- a/samples/angular/src/app/interceptors/auth.interceptor.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { HttpInterceptorFn } from '@angular/common/http'; -import { inject } from '@angular/core'; -import { from, switchMap } from 'rxjs'; -import { Auth, idToken } from '@angular/fire/auth'; -import { environment } from "../../environments/environment"; - -export const authInterceptor: HttpInterceptorFn = (req, next) => { - if (!req.url.startsWith(environment.keepTrackApiUrl)) { - return next(req); - } - - return from(idToken(inject(Auth))).pipe( - switchMap(token => { - if (token) { - // eslint-disable-next-line @typescript-eslint/naming-convention - return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); - } - return next(req); - }) - ); -}; diff --git a/samples/angular/src/app/inventory/base/data.component.ts b/samples/angular/src/app/inventory/base/data.component.ts deleted file mode 100644 index 2c8258c5..00000000 --- a/samples/angular/src/app/inventory/base/data.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {Directive, inject, OnDestroy, OnInit} from '@angular/core'; -import { Subscription } from 'rxjs'; -import { AuthenticateService } from 'src/app/user/services/authenticate.service'; -import { DataService } from 'src/app/backend/services/data.interface'; -import { BackendData } from 'src/app/backend/types/backend-data'; - -@Directive() -export abstract class DataComponent implements OnInit, OnDestroy { - protected abstract readonly dataService: DataService; - private authenticateService = inject(AuthenticateService); - userEventsSubscription: Subscription | undefined; - items: Array | undefined; - currentPage = 1; - pageSize = 50; - - ngOnInit() { - this.userEventsSubscription = this.authenticateService.authState$.subscribe(() => { - this.load('', 1); - }); - } - - ngOnDestroy() { - if (this.userEventsSubscription) { - this.userEventsSubscription.unsubscribe(); - } - } - - load(search: string, currentPage: number, filter?: T) { - this.currentPage = currentPage; - this.dataService.list(search, currentPage - 1, this.pageSize, filter).subscribe({ - next: (items) => this.items = items, - error: (error) => console.warn(error) - }); - } - - updateCurrentPage(event: Event, newValue: number, search?: string) { - event.preventDefault(); - if (newValue > 0) { - this.load(search ?? '', newValue); - } - } - - create(item: T) { - this.dataService.create(item).subscribe(created => { - this.items?.push(created); - this.resetInputFields(); - }); - } - - abstract resetInputFields(): void; - - startEditing(item: T) { - item.isEditable = true; - } - - cancel(item: T) { - item.isEditable = false; - - if (!item.id) { - return; - } - - this.dataService.get(item.id).subscribe(existing => { - item = existing; - }); - } - - update(item: T) { - this.dataService.update(item) - .subscribe(() => item.isEditable = false); - } - - delete(item: T) { - this.dataService.delete(item) - .subscribe(() => { - if (this.items) { - this.items.splice(this.items.findIndex(x => x.id === item.id), 1); - } - }); - } -} diff --git a/samples/angular/src/app/inventory/book/book.component.html b/samples/angular/src/app/inventory/book/book.component.html deleted file mode 100644 index e46a1b60..00000000 --- a/samples/angular/src/app/inventory/book/book.component.html +++ /dev/null @@ -1,129 +0,0 @@ -

Books

- -@if (!items) { -

Loading...

-} - -
- -
- -
-
- -
- @if (items) { - - - - - - - - - - - - @for (book of items; track book; let i = $index) { - - - - - - - - } - -
TitleAuthor(s)SeriesFinished dateActions
- @if (!book.isEditable ) { - {{ book.title }} - } - @if (book.isEditable) { -
- -
- } -
- @if (!book.isEditable ) { - {{ book.author }} - } - @if (book.isEditable) { -
- -
- } -
- @if (!book.isEditable ) { - {{ book.series }} - } - @if (book.isEditable) { -
- -
- } -
- @if (!book.isEditable ) { - {{ book.finishedAt | date: 'yyyy-MM-dd' }} - } - @if (book.isEditable) { -
- -
- } -
- @if (!book.isEditable) { - - } - @if (book.isEditable) { - - } - @if (book.isEditable) { - - } - @if (!book.isEditable) { - - } -
- } -
- -@if (items) { - -} - -@if (items) { -
-
-
-

Add

-
-
-
-
- - -
-
- - -
-
- - -
-
- -
- -
-
-
-
-
-} diff --git a/samples/angular/src/app/inventory/book/book.component.spec.ts b/samples/angular/src/app/inventory/book/book.component.spec.ts deleted file mode 100644 index 6145e221..00000000 --- a/samples/angular/src/app/inventory/book/book.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { User } from '@angular/fire/auth'; -import { Observable } from 'rxjs'; - -import { AuthenticateService } from 'src/app/user/services/authenticate.service'; -import { BookComponent } from './book.component'; -import { BookService } from 'src/app/backend/services/book.service'; - -describe('BookComponent', () => { - let fakeBookService: jasmine.SpyObj; - let fakeAuthenticateService: jasmine.SpyObj; - - beforeEach(() => { - const emptyUser = new Observable(); - fakeBookService = jasmine.createSpyObj('BookService', ['list']); - fakeAuthenticateService = jasmine.createSpyObj('AuthenticateService', [], { 'authState$': emptyUser }); - - TestBed.configureTestingModule({ - imports: [], - providers: [ - { provide: BookService, useValue: fakeBookService }, - { provide: AuthenticateService, useValue: fakeAuthenticateService } - ] - }); - }); - - it('should listen to userEvents in ngOnInit', waitForAsync(() => { - const fixture = TestBed.createComponent(BookComponent); - const component = fixture.componentInstance; - component.ngOnInit(); - })); -}); diff --git a/samples/angular/src/app/inventory/book/book.component.ts b/samples/angular/src/app/inventory/book/book.component.ts deleted file mode 100644 index 7295ec81..00000000 --- a/samples/angular/src/app/inventory/book/book.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Component, OnInit, OnDestroy, ElementRef, ViewChild, inject } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { BookService } from 'src/app/backend/services/book.service'; -import { Book } from 'src/app/backend/types/book'; -import { DataComponent } from '../base/data.component'; - -@Component({ - selector: 'app-book', - imports: [CommonModule, FormsModule], - templateUrl: './book.component.html' -}) -export class BookComponent extends DataComponent implements OnInit, OnDestroy { - protected override readonly dataService = inject(BookService); - - @ViewChild('titleInput') titleInput = {} as ElementRef; - @ViewChild('authorInput') authorInput = {} as ElementRef; - @ViewChild('seriesInput') seriesInput = {} as ElementRef; - @ViewChild('searchInput') searchInput = {} as ElementRef; - - resetInputFields() { - this.titleInput.nativeElement.value = ''; - this.authorInput.nativeElement.value = ''; - this.seriesInput.nativeElement.value = ''; - } -} diff --git a/samples/angular/src/app/inventory/car/car.component.html b/samples/angular/src/app/inventory/car/car.component.html deleted file mode 100644 index 7608405e..00000000 --- a/samples/angular/src/app/inventory/car/car.component.html +++ /dev/null @@ -1 +0,0 @@ -

car works!

diff --git a/samples/angular/src/app/inventory/car/car.component.spec.ts b/samples/angular/src/app/inventory/car/car.component.spec.ts deleted file mode 100644 index ce22e0cb..00000000 --- a/samples/angular/src/app/inventory/car/car.component.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; - -import { CarComponent } from './car.component'; - -describe('CarComponent', () => { - let component: CarComponent; - let fixture: ComponentFixture; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ CarComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(CarComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/samples/angular/src/app/inventory/car/car.component.ts b/samples/angular/src/app/inventory/car/car.component.ts deleted file mode 100644 index e3f9e46a..00000000 --- a/samples/angular/src/app/inventory/car/car.component.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Component } from '@angular/core'; -import { FormsModule } from "@angular/forms"; - -@Component({ - selector: 'app-car', - imports: [FormsModule], - templateUrl: './car.component.html' -}) -export class CarComponent { -} diff --git a/samples/angular/src/app/inventory/inventory.module.ts b/samples/angular/src/app/inventory/inventory.module.ts deleted file mode 100644 index ede9e107..00000000 --- a/samples/angular/src/app/inventory/inventory.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MovieComponent } from './movie/movie.component'; -import { BookComponent } from './book/book.component'; -import { CarComponent } from './car/car.component'; -import { VideoGameComponent } from './video-game/video-game.component'; -import { TvShowComponent } from './tv-show/tv-show.component'; -import { FormsModule } from '@angular/forms'; - -@NgModule({ - declarations: [ - MovieComponent, - BookComponent, - CarComponent, - VideoGameComponent, - TvShowComponent - ], - imports: [ - CommonModule, - FormsModule - ] -}) -export class InventoryModule { } diff --git a/samples/angular/src/app/inventory/movie/movie.component.html b/samples/angular/src/app/inventory/movie/movie.component.html deleted file mode 100644 index 85011b6e..00000000 --- a/samples/angular/src/app/inventory/movie/movie.component.html +++ /dev/null @@ -1,104 +0,0 @@ -

Movies

- -@if (!items) { -

Loading...

-} - -
- -
- -
-
- -
- @if (items) { - - - - - - - - - - @for (movie of items; track movie) { - - - - - - } - -
TitleYearActions
- @if (!movie.isEditable ) { - {{ movie.title }} - } - @else { -
- -
- } -
- @if (!movie.isEditable ) { - {{ movie.year }} - } - @else { -
- -
- } -
- @if (!movie.isEditable) { - - - } - @else { - - - } -
- } -
- -@if (items) { - -
-
-
-

Add

-
-
-
-
- - -
-
- - -
-
- -
- -
-
-
-
-
-} diff --git a/samples/angular/src/app/inventory/movie/movie.component.spec.ts b/samples/angular/src/app/inventory/movie/movie.component.spec.ts deleted file mode 100644 index 008b1aff..00000000 --- a/samples/angular/src/app/inventory/movie/movie.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { User } from '@angular/fire/auth'; -import { Observable } from 'rxjs'; - -import { AuthenticateService } from 'src/app/user/services/authenticate.service'; -import { MovieComponent } from './movie.component'; -import { MovieService } from 'src/app/backend/services/movie.service'; - -describe('MovieComponent', () => { - let fakeMovieService: jasmine.SpyObj; - let fakeAuthenticateService: jasmine.SpyObj; - - beforeEach(() => { - const emptyUser = new Observable(); - fakeMovieService = jasmine.createSpyObj('MovieService', ['list']); - fakeAuthenticateService = jasmine.createSpyObj('AuthenticateService', [], { 'authState$': emptyUser }); - - TestBed.configureTestingModule({ - imports: [], - providers: [ - { provide: MovieService, useValue: fakeMovieService }, - { provide: AuthenticateService, useValue: fakeAuthenticateService } - ] - }); - }); - - it('should listen to userEvents in ngOnInit', waitForAsync(() => { - const fixture = TestBed.createComponent(MovieComponent); - const component = fixture.componentInstance; - component.ngOnInit(); - })); -}); diff --git a/samples/angular/src/app/inventory/movie/movie.component.ts b/samples/angular/src/app/inventory/movie/movie.component.ts deleted file mode 100644 index cb0f3ed0..00000000 --- a/samples/angular/src/app/inventory/movie/movie.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, OnInit, OnDestroy, ViewChild, ElementRef, inject } from '@angular/core'; -import { CommonModule } from "@angular/common"; -import { FormsModule } from "@angular/forms"; -import { MovieService } from 'src/app/backend/services/movie.service'; -import { Movie } from 'src/app/backend/types/movie'; -import { DataComponent } from '../base/data.component'; - -@Component({ - selector: 'app-movie', - imports: [CommonModule, FormsModule], - templateUrl: './movie.component.html' -}) -export class MovieComponent extends DataComponent implements OnInit, OnDestroy { - protected override readonly dataService = inject(MovieService); - - @ViewChild('titleInput') titleInput= {} as ElementRef; - @ViewChild('yearInput') yearInput= {} as ElementRef; - - resetInputFields() { - this.titleInput.nativeElement.value = ''; - this.yearInput.nativeElement.value = ''; - } -} diff --git a/samples/angular/src/app/inventory/tv-show/tv-show.component.html b/samples/angular/src/app/inventory/tv-show/tv-show.component.html deleted file mode 100644 index ae3298fc..00000000 --- a/samples/angular/src/app/inventory/tv-show/tv-show.component.html +++ /dev/null @@ -1,59 +0,0 @@ -

TV shows

- -@if (!items) { -

Loading...

-} - -
- -
- -
-
- -@if (items) { - - - - - - - - - @for (tvShow of items; track tvShow) { - - - - - } - -
TitleActions
{{ tvShow.title }}
-} - -@if (items) { - -
-
-

Add

-
-
-
-
- - -
-
- -
- -
-
-
-
-} diff --git a/samples/angular/src/app/inventory/tv-show/tv-show.component.spec.ts b/samples/angular/src/app/inventory/tv-show/tv-show.component.spec.ts deleted file mode 100644 index 9c5ab05f..00000000 --- a/samples/angular/src/app/inventory/tv-show/tv-show.component.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { User } from '@angular/fire/auth'; -import { Observable } from 'rxjs'; - -import { AppModule } from 'src/app/app.module'; -import { AuthenticateService } from 'src/app/user/services/authenticate.service'; -import { TvShowComponent } from './tv-show.component'; -import { TvShowService } from 'src/app/backend/services/tv-show.service'; - -describe('TvShowComponent', () => { - let fakeTvShowService: jasmine.SpyObj; - let fakeAuthenticateService: jasmine.SpyObj; - - beforeEach(() => { - const emptyUser = new Observable(); - fakeTvShowService = jasmine.createSpyObj('TvShowService', ['list']); - fakeAuthenticateService = jasmine.createSpyObj('AuthenticateService', [], { 'authState$': emptyUser }); - - TestBed.configureTestingModule({ - imports: [AppModule], - providers: [ - { provide: TvShowService, useValue: fakeTvShowService }, - { provide: AuthenticateService, useValue: fakeAuthenticateService } - ] - }); - }); - - it('should listen to userEvents in ngOnInit', waitForAsync(() => { - const fixture = TestBed.createComponent(TvShowComponent); - const component = fixture.componentInstance; - component.ngOnInit(); - })); -}); diff --git a/samples/angular/src/app/inventory/tv-show/tv-show.component.ts b/samples/angular/src/app/inventory/tv-show/tv-show.component.ts deleted file mode 100644 index bb44ed05..00000000 --- a/samples/angular/src/app/inventory/tv-show/tv-show.component.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; -import { CommonModule } from "@angular/common"; -import { FormsModule } from "@angular/forms"; -import { TvShowService } from 'src/app/backend/services/tv-show.service'; -import { TvShow } from 'src/app/backend/types/tv-show'; -import { DataComponent } from '../base/data.component'; - -@Component({ - selector: 'app-tv-show', - imports: [CommonModule, FormsModule], - templateUrl: './tv-show.component.html' -}) -export class TvShowComponent extends DataComponent implements OnInit, OnDestroy { - protected override readonly dataService = inject(TvShowService); - - @ViewChild('titleInput') titleInput= {} as ElementRef; - - resetInputFields() { - this.titleInput.nativeElement.value = ''; - } -} diff --git a/samples/angular/src/app/inventory/video-game/video-game.component.html b/samples/angular/src/app/inventory/video-game/video-game.component.html deleted file mode 100644 index 7f8694a0..00000000 --- a/samples/angular/src/app/inventory/video-game/video-game.component.html +++ /dev/null @@ -1,175 +0,0 @@ -

Video games

- -@if (!items) { -

Loading...

-} - -
- -
- -
-
- -
-
- -
-
- -
- @if (items) { - - - - - - - - - - - - @for (videoGame of items; track videoGame) { - - - - - - - - } - -
TitlePlatformStateFinished dateActions
- @if (!videoGame.isEditable ) { - {{ videoGame.title }} - } - @else { -
- -
- } -
- @if (!videoGame.isEditable ) { - {{ videoGame.platform }} - } - @else { -
- -
- } -
- @if (!videoGame.isEditable ) { - {{ videoGame.state }} - } - @else { -
- -
- } -
- @if (!videoGame.isEditable ) { - {{ videoGame.finishedAt | date: 'yyyy-MM-dd' }} - } - @else { -
- -
- } -
- @if (!videoGame.isEditable) { - - - } - @else { - - - } -
- } -
- -@if (items) { - -} - -@if (items) { -
-
-
-

Add

-
-
-
-
- - -
-
- - -
-
- - -
-
- -
- -
-
-
-
-
-} diff --git a/samples/angular/src/app/inventory/video-game/video-game.component.spec.ts b/samples/angular/src/app/inventory/video-game/video-game.component.spec.ts deleted file mode 100644 index d9ce95a6..00000000 --- a/samples/angular/src/app/inventory/video-game/video-game.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { TestBed, waitForAsync } from '@angular/core/testing'; -import { User } from '@angular/fire/auth'; -import { Observable } from 'rxjs'; - -import { AuthenticateService } from 'src/app/user/services/authenticate.service'; -import { VideoGameComponent } from './video-game.component'; -import { VideoGameService } from 'src/app/backend/services/video-game.service'; - -describe('VideoGameComponent', () => { - let fakeVideoGameService: jasmine.SpyObj; - let fakeAuthenticateService: jasmine.SpyObj; - - beforeEach(() => { - const emptyUser = new Observable(); - fakeVideoGameService = jasmine.createSpyObj('VideoGameService', ['list']); - fakeAuthenticateService = jasmine.createSpyObj('AuthenticateService', [], { 'authState$': emptyUser }); - - TestBed.configureTestingModule({ - imports: [], - providers: [ - { provide: VideoGameService, useValue: fakeVideoGameService }, - { provide: AuthenticateService, useValue: fakeAuthenticateService } - ] - }); - }); - - it('should listen to userEvents in ngOnInit', waitForAsync(() => { - const fixture = TestBed.createComponent(VideoGameComponent); - const component = fixture.componentInstance; - component.ngOnInit(); - })); -}); diff --git a/samples/angular/src/app/inventory/video-game/video-game.component.ts b/samples/angular/src/app/inventory/video-game/video-game.component.ts deleted file mode 100644 index b5047879..00000000 --- a/samples/angular/src/app/inventory/video-game/video-game.component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Component, ElementRef, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; -import { CommonModule } from "@angular/common"; -import { FormsModule } from "@angular/forms"; -import { VideoGameService } from 'src/app/backend/services/video-game.service'; -import { VideoGame } from 'src/app/backend/types/video-game'; -import { DataComponent } from '../base/data.component'; - -@Component({ - selector: 'app-video-game', - imports: [CommonModule, FormsModule], - templateUrl: './video-game.component.html' -}) -export class VideoGameComponent extends DataComponent implements OnInit, OnDestroy { - protected override readonly dataService = inject(VideoGameService); - - @ViewChild('titleInput') titleInput = {} as ElementRef; - @ViewChild('platformInput') platformInput= {} as ElementRef; - @ViewChild('stateInput') stateInput= {} as ElementRef; - - resetInputFields() { - this.titleInput.nativeElement.value = ''; - this.platformInput.nativeElement.value = ''; - this.stateInput.nativeElement.value = ''; - } -} diff --git a/samples/angular/src/app/layout/header/header.component.css b/samples/angular/src/app/layout/header/header.component.css deleted file mode 100644 index 10389ef9..00000000 --- a/samples/angular/src/app/layout/header/header.component.css +++ /dev/null @@ -1,18 +0,0 @@ -a.navbar-brand { - white-space: normal; - text-align: center; - word-break: break-all; -} - -html { - font-size: 14px; -} -@media (min-width: 768px) { - html { - font-size: 16px; - } -} - -.box-shadow { - box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05); -} diff --git a/samples/angular/src/app/layout/header/header.component.html b/samples/angular/src/app/layout/header/header.component.html deleted file mode 100644 index c51e253a..00000000 --- a/samples/angular/src/app/layout/header/header.component.html +++ /dev/null @@ -1,45 +0,0 @@ -
- -
diff --git a/samples/angular/src/app/layout/header/header.component.ts b/samples/angular/src/app/layout/header/header.component.ts deleted file mode 100644 index fcb40801..00000000 --- a/samples/angular/src/app/layout/header/header.component.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Component, OnInit, OnDestroy, inject } from '@angular/core'; -import { User } from '@angular/fire/auth'; -import { Router, RouterLink, RouterLinkActive } from '@angular/router'; -import { CommonModule } from "@angular/common"; -import { Subscription } from 'rxjs'; -import { AuthenticateService } from 'src/app/user/services/authenticate.service'; - -@Component({ - selector: 'app-header', - imports: [ - CommonModule, - RouterLink, - RouterLinkActive - ], - templateUrl: './header.component.html', - styleUrls: ['./header.component.css'] -}) -export class HeaderComponent implements OnInit, OnDestroy { - private authenticateService = inject(AuthenticateService); - private router = inject(Router); - - isExpanded = false; - - user = null as User | null; - userEventsSubscription: Subscription | undefined; - - ngOnInit() { - this.userEventsSubscription = this.authenticateService.authState$.subscribe({ - next: (user: User | null) => this.user = user, - error: (error) => console.log(error) - }); - } - - ngOnDestroy() { - if (this.userEventsSubscription) { - this.userEventsSubscription.unsubscribe(); - } - } - - async logout(event: Event) { - event.preventDefault(); - await this.authenticateService.logout(); - await this.router.navigate(['/login']); - } - - collapse() { - this.isExpanded = false; - } - - toggle() { - this.isExpanded = !this.isExpanded; - } - -} diff --git a/samples/angular/src/app/layout/layout.module.ts b/samples/angular/src/app/layout/layout.module.ts deleted file mode 100644 index 10fcaf58..00000000 --- a/samples/angular/src/app/layout/layout.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { RouterModule } from '@angular/router'; - -@NgModule({ - declarations: [ ], - imports: [ - CommonModule, - RouterModule - ] -}) -export class LayoutModule { } diff --git a/samples/angular/src/app/user/login/login.component.html b/samples/angular/src/app/user/login/login.component.html deleted file mode 100644 index 2287fd8e..00000000 --- a/samples/angular/src/app/user/login/login.component.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/samples/angular/src/app/user/login/login.component.spec.ts b/samples/angular/src/app/user/login/login.component.spec.ts deleted file mode 100644 index 41140adf..00000000 --- a/samples/angular/src/app/user/login/login.component.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { LoginComponent } from './login.component'; -import { AuthenticateService } from '../services/authenticate.service'; - -describe('LoginComponent', () => { - - const fakeAuthenticateService = jasmine.createSpyObj('AuthenticateService', ['signInWithGitHub']); - - beforeEach(() => TestBed.configureTestingModule({ - imports: [], - providers: [ - { provide: AuthenticateService, useValue: fakeAuthenticateService } - ] - })); - - beforeEach(() => { - fakeAuthenticateService.signInWithGitHub.calls.reset(); - }); - - it('should have a title', () => { - const fixture = TestBed.createComponent(LoginComponent); - - // when we trigger the change detection - fixture.detectChanges(); - - // then we should have a title - const element = fixture.nativeElement; - expect(element.querySelector('button')).not.toBeNull('The template should have a `h1` tag'); - }); -}); diff --git a/samples/angular/src/app/user/login/login.component.ts b/samples/angular/src/app/user/login/login.component.ts deleted file mode 100644 index b3e3101d..00000000 --- a/samples/angular/src/app/user/login/login.component.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { Router } from '@angular/router'; -import { AuthenticateService } from '../services/authenticate.service'; - -@Component({ - selector: 'app-login', - templateUrl: './login.component.html' -}) -export class LoginComponent { - private authenticateService = inject(AuthenticateService); - private router = inject(Router); - - async signInWithGitHub() { - await this.authenticateService.signInWithGitHub(); - await this.router.navigate(['/']); - } -} diff --git a/samples/angular/src/app/user/models/user.model.ts b/samples/angular/src/app/user/models/user.model.ts deleted file mode 100644 index 88056c0a..00000000 --- a/samples/angular/src/app/user/models/user.model.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface UserModel { - username: string; - token?: string; -} diff --git a/samples/angular/src/app/user/services/authenticate.service.spec.ts b/samples/angular/src/app/user/services/authenticate.service.spec.ts deleted file mode 100644 index 2005ee7c..00000000 --- a/samples/angular/src/app/user/services/authenticate.service.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { AngularFireModule } from '@angular/fire/compat'; -import { AngularFireAuthModule } from '@angular/fire/compat/auth'; -import { AuthenticateService } from './authenticate.service'; -import { environment } from 'src/environments/environment.dev'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -describe('AuthenticateService', () => { - let authenticateService: AuthenticateService; - let http: HttpTestingController; - - beforeEach(() => TestBed.configureTestingModule({ - imports: [ - AngularFireModule.initializeApp(environment.firebase), - AngularFireAuthModule], - providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] -})); - - beforeEach(() => { - http = TestBed.inject(HttpTestingController); - authenticateService = TestBed.inject(AuthenticateService); - }); - - afterAll(() => http.verify()); - - it('should logout', () => { - authenticateService.logout(); - }); -}); diff --git a/samples/angular/src/app/user/services/authenticate.service.ts b/samples/angular/src/app/user/services/authenticate.service.ts deleted file mode 100644 index bfb694b2..00000000 --- a/samples/angular/src/app/user/services/authenticate.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { Auth, GithubAuthProvider, authState, signInWithPopup } from '@angular/fire/auth'; - -// see https://github.com/angular/angularfire/blob/main/docs/auth.md -@Injectable({ - providedIn: 'root' -}) -export class AuthenticateService { - private auth: Auth = inject(Auth); - authState$ = authState(this.auth); - - // see https://firebase.google.com/docs/auth/web/github-auth - async signInWithGitHub() { - try { - await signInWithPopup(this.auth, new GithubAuthProvider()); - } catch (error) { - console.log(error); - } - } - - async logout() { - await this.auth.signOut(); - } -} diff --git a/samples/angular/src/app/user/user.module.ts b/samples/angular/src/app/user/user.module.ts deleted file mode 100644 index 06108402..00000000 --- a/samples/angular/src/app/user/user.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -import { AuthenticateService } from './services/authenticate.service'; -import { LoginComponent } from './login/login.component'; - -@NgModule({ - declarations: [LoginComponent], - imports: [ - CommonModule - ], - providers: [ - AuthenticateService - ] -}) -export class UserModule { } diff --git a/samples/angular/src/assets/.gitkeep b/samples/angular/src/assets/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/samples/angular/src/environments/environment.ts b/samples/angular/src/environments/environment.ts deleted file mode 100644 index b25fe8c1..00000000 --- a/samples/angular/src/environments/environment.ts +++ /dev/null @@ -1,26 +0,0 @@ -// This file can be replaced during build by using the `fileReplacements` array. -// `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. -// The list of file replacements can be found in `angular.json`. - -export const environment = { - production: false, - keepTrackApiUrl: '', - firebase: { - apiKey: '', - authDomain: '', - databaseURL: '', - projectId: '', - storageBucket: '', - messagingSenderId: '', - appId: '', - measurementId: '' - } -}; - -/* - * In development mode, to ignore zone related error stack frames such as - * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can - * import the following file, but please comment it out in production mode - * because it will have performance impact when throw error - */ -// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. diff --git a/samples/angular/src/favicon.ico b/samples/angular/src/favicon.ico deleted file mode 100644 index 997406ad22c29aae95893fb3d666c30258a09537..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 948 zcmV;l155mgP)CBYU7IjCFmI-B}4sMJt3^s9NVg!P0 z6hDQy(L`XWMkB@zOLgN$4KYz;j0zZxq9KKdpZE#5@k0crP^5f9KO};h)ZDQ%ybhht z%t9#h|nu0K(bJ ztIkhEr!*UyrZWQ1k2+YkGqDi8Z<|mIN&$kzpKl{cNP=OQzXHz>vn+c)F)zO|Bou>E z2|-d_=qY#Y+yOu1a}XI?cU}%04)zz%anD(XZC{#~WreV!a$7k2Ug`?&CUEc0EtrkZ zL49MB)h!_K{H(*l_93D5tO0;BUnvYlo+;yss%n^&qjt6fZOa+}+FDO(~2>G z2dx@=JZ?DHP^;b7*Y1as5^uphBsh*s*z&MBd?e@I>-9kU>63PjP&^#5YTOb&x^6Cf z?674rmSHB5Fk!{Gv7rv!?qX#ei_L(XtwVqLX3L}$MI|kJ*w(rhx~tc&L&xP#?cQow zX_|gx$wMr3pRZIIr_;;O|8fAjd;1`nOeu5K(pCu7>^3E&D2OBBq?sYa(%S?GwG&_0-s%_v$L@R!5H_fc)lOb9ZoOO#p`Nn`KU z3LTTBtjwo`7(HA6 z7gmO$yTR!5L>Bsg!X8616{JUngg_@&85%>W=mChTR;x4`P=?PJ~oPuy5 zU-L`C@_!34D21{fD~Y8NVnR3t;aqZI3fIhmgmx}$oc-dKDC6Ap$Gy>a!`A*x2L1v0 WcZ@i?LyX}70000 - - - - Keeptrack - - - - - - Loading... - - diff --git a/samples/angular/src/main.ts b/samples/angular/src/main.ts deleted file mode 100644 index 5888d1ae..00000000 --- a/samples/angular/src/main.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { enableProdMode, provideZoneChangeDetection } from '@angular/core'; -import { bootstrapApplication } from '@angular/platform-browser'; -import { provideRouter } from '@angular/router'; -import { provideHttpClient, withInterceptors } from '@angular/common/http'; -import { provideFirebaseApp, initializeApp } from '@angular/fire/app'; -import { provideAuth, getAuth } from '@angular/fire/auth'; - -import { AppComponent } from './app/app.component'; -import { routes } from './app/app.routes'; -import { authInterceptor } from "./app/interceptors/auth.interceptor"; -import { environment } from './environments/environment'; - -if (environment.production) { - enableProdMode(); -} - -bootstrapApplication(AppComponent, { - providers: [ - provideZoneChangeDetection(),provideRouter(routes), - provideHttpClient(withInterceptors([authInterceptor])), - provideFirebaseApp(() => initializeApp(environment.firebase)), - provideAuth(() => getAuth()), - provideHttpClient( - withInterceptors([authInterceptor]) - ) - ] -}).catch(err => console.error(err)); diff --git a/samples/angular/src/styles.css b/samples/angular/src/styles.css deleted file mode 100644 index 9e5d916d..00000000 --- a/samples/angular/src/styles.css +++ /dev/null @@ -1,18 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ - -@import 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css'; - -/* Provide sufficient contrast against white background */ -a { - color: #0366d6; -} - -code { - color: #e01a76; -} - -.btn-primary { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} diff --git a/samples/angular/src/web.config b/samples/angular/src/web.config deleted file mode 100644 index 5ec2c9d6..00000000 --- a/samples/angular/src/web.config +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/samples/angular/tsconfig.app.json b/samples/angular/tsconfig.app.json deleted file mode 100644 index 7d7c716d..00000000 --- a/samples/angular/tsconfig.app.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/app", - "types": [] - }, - "files": [ - "src/main.ts" - ], - "include": [ - "src/**/*.d.ts" - ] -} diff --git a/samples/angular/tsconfig.json b/samples/angular/tsconfig.json deleted file mode 100644 index 213dba09..00000000 --- a/samples/angular/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -/* Ref. https://angular.io/config/tsconfig */ -{ - "compileOnSave": false, - "compilerOptions": { - "baseUrl": "./", - "outDir": "./dist/out-tsc", - "forceConsistentCasingInFileNames": true, - "esModuleInterop": true, - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noUnusedLocals": true, - "sourceMap": true, - "declaration": false, - "experimentalDecorators": true, - "moduleResolution": "bundler", - "importHelpers": true, - "target": "ES2022", - "module": "ES2022", - "useDefineForClassFields": false - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true, - "extendedDiagnostics": { - "defaultCategory": "error" - } - } -} diff --git a/samples/angular/tsconfig.spec.json b/samples/angular/tsconfig.spec.json deleted file mode 100644 index b18619fd..00000000 --- a/samples/angular/tsconfig.spec.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": [ - "jasmine" - ] - }, - "include": [ - "src/**/*.spec.ts", - "src/**/*.d.ts" - ] -} From 6ffd0d5c64cf76ce22cc885b75be65af7eb377c3 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 23:26:43 +0200 Subject: [PATCH 21/35] Add Amazon product link --- scripts/mongodb-create-index.js | 4 +- .../Components/Account/Pages/Manage.razor | 19 +++++-- .../Account/UserPreferencesState.cs | 25 +++++++++ .../Inventory/Pages/BookDetail.razor | 4 +- .../Inventory/Shared/OwnedVersionFields.razor | 21 ++++++- src/BlazorApp/Program.cs | 1 + src/Common.System/AmazonReference.cs | 56 +++++++++++++++++++ .../Models/UserPreferencesFeaturesModel.cs | 23 ++++++++ src/Domain/Models/UserPreferencesModel.cs | 13 ++--- .../Entities/UserPreferences.cs | 4 +- .../Entities/UserPreferencesFeatures.cs | 12 ++++ .../Repositories/UserPreferencesRepository.cs | 2 +- .../Dto/UserPreferencesDto.cs | 6 +- .../Dto/UserPreferencesFeaturesDto.cs | 15 +++++ .../Resources/UserPreferencesResourceTest.cs | 10 ++-- .../Services/AmazonReferenceTest.cs | 49 ++++++++++++++++ 16 files changed, 232 insertions(+), 32 deletions(-) create mode 100644 src/BlazorApp/Components/Account/UserPreferencesState.cs create mode 100644 src/Common.System/AmazonReference.cs create mode 100644 src/Domain/Models/UserPreferencesFeaturesModel.cs create mode 100644 src/Infrastructure.MongoDb/Entities/UserPreferencesFeatures.cs create mode 100644 src/WebApi.Contracts/Dto/UserPreferencesFeaturesDto.cs create mode 100644 test/WebApi.UnitTests/Services/AmazonReferenceTest.cs diff --git a/scripts/mongodb-create-index.js b/scripts/mongodb-create-index.js index da7ceaa1..dba3c20b 100644 --- a/scripts/mongodb-create-index.js +++ b/scripts/mongodb-create-index.js @@ -271,7 +271,7 @@ ensureIndex( { name: "album_reference_discogs_id", unique: true, partialFilterExpression: { "external_ids.discogs": { $exists: true } } } ); -// user_preferences: exactly one document per owner (upserted by owner_id, never listed) - the unique +// user_preference: exactly one document per owner (upserted by owner_id, never listed) - the unique // index is what actually guarantees that, the same way the application-level upsert-by-owner-id logic in // UserPreferencesRepository is only "supposed to" prevent a second document. -ensureIndex(db.user_preferences, { owner_id: 1 }, { name: "user_preferences_owner", unique: true }); +ensureIndex(db.user_preference, { owner_id: 1 }, { name: "user_preference_owner", unique: true }); diff --git a/src/BlazorApp/Components/Account/Pages/Manage.razor b/src/BlazorApp/Components/Account/Pages/Manage.razor index 8761af71..162b95cf 100644 --- a/src/BlazorApp/Components/Account/Pages/Manage.razor +++ b/src/BlazorApp/Components/Account/Pages/Manage.razor @@ -1,6 +1,6 @@ @page "/account/manage" @attribute [Authorize] -@inject UserPreferencesApiClient PreferencesApi +@inject UserPreferencesState PreferencesState

Manage

@@ -17,11 +17,18 @@

Preferences

+ checked="@_preferences.Features.ShowChasseAuxLivresLink" @onchange="e => SetFeatureAsync((f, v) => f.ShowChasseAuxLivresLink = v, (bool)e.Value!)"/>
+
+ + +
} @code { @@ -29,13 +36,13 @@ protected override async Task OnInitializedAsync() { - _preferences = await PreferencesApi.GetAsync(); + _preferences = await PreferencesState.GetAsync(); } - private async Task SetShowChasseAuxLivresLinkAsync(bool value) + private async Task SetFeatureAsync(Action apply, bool value) { if (_preferences is null) return; - _preferences.ShowChasseAuxLivresLink = value; - await PreferencesApi.UpdateAsync(_preferences); + apply(_preferences.Features, value); + await PreferencesState.UpdateAsync(_preferences); } } diff --git a/src/BlazorApp/Components/Account/UserPreferencesState.cs b/src/BlazorApp/Components/Account/UserPreferencesState.cs new file mode 100644 index 00000000..61db8cba --- /dev/null +++ b/src/BlazorApp/Components/Account/UserPreferencesState.cs @@ -0,0 +1,25 @@ +using Keeptrack.WebApi.Contracts.Dto; + +namespace Keeptrack.BlazorApp.Components.Account; + +/// +/// Circuit-scoped cache of the caller's own (registered AddScoped). +/// Preference checks now happen in more than one place per page (e.g. every owned copy's +/// OwnedVersionFields instance on a detail page), so each consumer fetching independently would fire +/// one HTTP request per instance; this fetches once per circuit and every consumer shares the same +/// in-flight/completed instead. (only called from +/// Manage.razor) refreshes the cache immediately, so a page navigated to afterward sees the new value +/// without a second round trip. +/// +public sealed class UserPreferencesState(UserPreferencesApiClient api) +{ + private Task? _cached; + + public Task GetAsync() => _cached ??= api.GetAsync(); + + public async Task UpdateAsync(UserPreferencesDto dto) + { + await api.UpdateAsync(dto); + _cached = Task.FromResult(dto); + } +} diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index ef65297f..a9d4c17c 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -157,7 +157,7 @@ else [Inject] private ReferenceDataApiClient ReferenceDataApi { get; set; } = null!; - [Inject] private UserPreferencesApiClient PreferencesApi { get; set; } = null!; + [Inject] private UserPreferencesState PreferencesState { get; set; } = null!; private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); @@ -210,7 +210,7 @@ else { Book = await BookApi.GetOneAsync(Id); Reference = string.IsNullOrEmpty(Book?.ReferenceId) ? null : await ReferenceDataApi.GetBookAsync(Book.ReferenceId); - ShowChasseAuxLivresLink = (await PreferencesApi.GetAsync()).ShowChasseAuxLivresLink; + ShowChasseAuxLivresLink = (await PreferencesState.GetAsync()).Features.ShowChasseAuxLivresLink; } private async Task SetRatingAsync(float rating) diff --git a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor index cc11943e..5799f4f4 100644 --- a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor +++ b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor @@ -1,4 +1,5 @@ @using System.Globalization +@inject UserPreferencesState PreferencesState @* Shared owned-copy field body (Physical/Digital toggle + price/acquired/vendor/reference) - used by OwnedVersionsEditor.razor (movie/TV show/book/album), VideoGameDetail.razor's per-platform cards, and @@ -39,8 +40,17 @@
- +
+ + @if (_showAmazonProductLink && AmazonReference.TryExtractAsin(Copy.Reference) is { } asin) + { + + + + } +
@ExtraFields
@@ -54,6 +64,13 @@ [Parameter] public RenderFragment? ExtraFields { get; set; } + private bool _showAmazonProductLink; + + protected override async Task OnInitializedAsync() + { + _showAmazonProductLink = (await PreferencesState.GetAsync()).Features.ShowAmazonProductLink; + } + private Task SetCopyTypeAsync(CopyType copyType) { Copy.CopyType = copyType; diff --git a/src/BlazorApp/Program.cs b/src/BlazorApp/Program.cs index f9dd2da6..19247232 100644 --- a/src/BlazorApp/Program.cs +++ b/src/BlazorApp/Program.cs @@ -43,6 +43,7 @@ } builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddWebApiHttpClient(builder.Configuration.TryGetSection("WebApi:BaseUrl")); builder.Services.AddHealthChecks(); diff --git a/src/Common.System/AmazonReference.cs b/src/Common.System/AmazonReference.cs new file mode 100644 index 00000000..f9c57d50 --- /dev/null +++ b/src/Common.System/AmazonReference.cs @@ -0,0 +1,56 @@ +using System; +using System.Text.RegularExpressions; + +namespace Keeptrack.Common.System; + +/// +/// The read side of an Amazon-imported owned copy's Reference text (written by +/// AmazonImportMergeService.FormatOrderReference in the Domain project as +/// "Amazon order {orderId} (ASIN {asin})"). Lives here, not next to the writer, because it also +/// needs to be callable from BlazorApp (which can't reference Domain) to build an "open on Amazon" link - +/// keeping both halves of the same string shape in one place avoids the two drifting apart independently. +/// +public static partial class AmazonReference +{ + [GeneratedRegex(@"\(ASIN\s+([A-Za-z0-9]{10})\)")] + private static partial Regex AsinPattern(); + + /// + /// The ASIN embedded in an owned copy's Reference text, or null when it isn't there (most + /// copies weren't imported from Amazon at all). + /// + public static string? TryExtractAsin(string? reference) + { + if (string.IsNullOrEmpty(reference)) + { + return null; + } + + var match = AsinPattern().Match(reference); + return match.Success ? match.Groups[1].Value : null; + } + + /// + /// Amazon's own permalink shape (works on any of its marketplaces). is the + /// owned copy's own Vendor field - for an Amazon-imported copy this is the CSV export's "Website" + /// column (e.g. "Amazon.fr"), so a mixed-marketplace order history still opens on the right site + /// instead of always guessing one locale. Falls back to amazon.fr when the vendor isn't recognizably + /// an Amazon domain (a manually-entered copy, or an older import that didn't carry one). + /// + public static string BuildProductUrl(string asin, string? vendor) => $"https://{TryGetAmazonDomain(vendor) ?? "www.amazon.fr"}/dp/{asin}"; + + private static string? TryGetAmazonDomain(string? vendor) + { + var trimmed = vendor?.Trim(); + if (string.IsNullOrEmpty(trimmed) || !trimmed.Contains("amazon", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var withoutScheme = SchemePrefix().Replace(trimmed, ""); + return withoutScheme.Split('/')[0].ToLowerInvariant(); + } + + [GeneratedRegex(@"^https?://", RegexOptions.IgnoreCase)] + private static partial Regex SchemePrefix(); +} diff --git a/src/Domain/Models/UserPreferencesFeaturesModel.cs b/src/Domain/Models/UserPreferencesFeaturesModel.cs new file mode 100644 index 00000000..efd6c0c3 --- /dev/null +++ b/src/Domain/Models/UserPreferencesFeaturesModel.cs @@ -0,0 +1,23 @@ +namespace Keeptrack.Domain.Models; + +/// +/// The actual opt-in/opt-out toggles held by . A new feature of +/// this kind adds its own named boolean property here, the same convention every other flag in this +/// codebase already follows (e.g. ) - not a generic settings dictionary. +/// +public class UserPreferencesFeaturesModel +{ + /// + /// Shows an "open in a new tab" link to https://www.chasse-aux-livres.fr/{isbn} next to a book's ISBN + /// field on BookDetail.razor, for users who use that site to price-check/verify French books. + /// + public bool ShowChasseAuxLivresLink { get; set; } + + /// + /// Shows an "open on Amazon" link next to an owned copy's Reference field + /// (OwnedVersionFields.razor, shared by every owned-copy type) whenever + /// finds an ASIN in it - i.e. the copy was + /// created by the Amazon order-history import. + /// + public bool ShowAmazonProductLink { get; set; } +} diff --git a/src/Domain/Models/UserPreferencesModel.cs b/src/Domain/Models/UserPreferencesModel.cs index 79ebc5ed..355023b0 100644 --- a/src/Domain/Models/UserPreferencesModel.cs +++ b/src/Domain/Models/UserPreferencesModel.cs @@ -3,10 +3,9 @@ namespace Keeptrack.Domain.Models; /// -/// One document per user, holding opt-in/opt-out toggles for features that are useful but not something -/// every user wants surfaced (see ). A new feature of -/// this kind adds its own named boolean property here, the same convention every other flag in this -/// codebase already follows (e.g. ) - not a generic settings dictionary. +/// One document per user (see ). The opt-in/opt-out +/// toggles themselves live under , not as flat properties here, so a new one only +/// ever means adding a property to . /// public class UserPreferencesModel : IHasIdAndOwnerId { @@ -14,9 +13,5 @@ public class UserPreferencesModel : IHasIdAndOwnerId public required string OwnerId { get; set; } - /// - /// Shows an "open in a new tab" link to https://www.chasse-aux-livres.fr/{isbn} next to a book's ISBN - /// field on BookDetail.razor, for users who use that site to price-check/verify French books. - /// - public bool ShowChasseAuxLivresLink { get; set; } + public UserPreferencesFeaturesModel Features { get; set; } = new(); } diff --git a/src/Infrastructure.MongoDb/Entities/UserPreferences.cs b/src/Infrastructure.MongoDb/Entities/UserPreferences.cs index 3c2c318a..00dbd811 100644 --- a/src/Infrastructure.MongoDb/Entities/UserPreferences.cs +++ b/src/Infrastructure.MongoDb/Entities/UserPreferences.cs @@ -13,6 +13,6 @@ public class UserPreferences : IHasIdAndOwnerId [BsonElement("owner_id")] public required string OwnerId { get; set; } - [BsonElement("show_chasse_aux_livres_link")] - public bool ShowChasseAuxLivresLink { get; set; } + [BsonElement("features")] + public UserPreferencesFeatures Features { get; set; } = new(); } diff --git a/src/Infrastructure.MongoDb/Entities/UserPreferencesFeatures.cs b/src/Infrastructure.MongoDb/Entities/UserPreferencesFeatures.cs new file mode 100644 index 00000000..179fe177 --- /dev/null +++ b/src/Infrastructure.MongoDb/Entities/UserPreferencesFeatures.cs @@ -0,0 +1,12 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace Keeptrack.Infrastructure.MongoDb.Entities; + +public class UserPreferencesFeatures +{ + [BsonElement("show_chasse_aux_livres_link")] + public bool ShowChasseAuxLivresLink { get; set; } + + [BsonElement("show_amazon_product_link")] + public bool ShowAmazonProductLink { get; set; } +} diff --git a/src/Infrastructure.MongoDb/Repositories/UserPreferencesRepository.cs b/src/Infrastructure.MongoDb/Repositories/UserPreferencesRepository.cs index 12cbd2cd..e06706b0 100644 --- a/src/Infrastructure.MongoDb/Repositories/UserPreferencesRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/UserPreferencesRepository.cs @@ -9,7 +9,7 @@ namespace Keeptrack.Infrastructure.MongoDb.Repositories; public class UserPreferencesRepository(IMongoDatabase mongoDatabase, UserPreferencesStorageMapper mapper) : IUserPreferencesRepository { - private const string CollectionName = "user_preferences"; + private const string CollectionName = "user_preference"; private IMongoCollection Collection => mongoDatabase.GetCollection(CollectionName); diff --git a/src/WebApi.Contracts/Dto/UserPreferencesDto.cs b/src/WebApi.Contracts/Dto/UserPreferencesDto.cs index 79f9d0b2..d37d9372 100644 --- a/src/WebApi.Contracts/Dto/UserPreferencesDto.cs +++ b/src/WebApi.Contracts/Dto/UserPreferencesDto.cs @@ -1,12 +1,12 @@ namespace Keeptrack.WebApi.Contracts.Dto; /// -/// The caller's own opt-in/opt-out feature toggles. Always "mine" - never referenced by id, never listed. +/// The caller's own preferences. Always "mine" - never referenced by id, never listed. /// public class UserPreferencesDto { /// - /// Shows an "open in a new tab" link to chasse-aux-livres.fr next to a book's ISBN field. + /// The caller's opt-in/opt-out feature toggles. /// - public bool ShowChasseAuxLivresLink { get; set; } + public UserPreferencesFeaturesDto Features { get; set; } = new(); } diff --git a/src/WebApi.Contracts/Dto/UserPreferencesFeaturesDto.cs b/src/WebApi.Contracts/Dto/UserPreferencesFeaturesDto.cs new file mode 100644 index 00000000..590adada --- /dev/null +++ b/src/WebApi.Contracts/Dto/UserPreferencesFeaturesDto.cs @@ -0,0 +1,15 @@ +namespace Keeptrack.WebApi.Contracts.Dto; + +public class UserPreferencesFeaturesDto +{ + /// + /// Shows an "open in a new tab" link to chasse-aux-livres.fr next to a book's ISBN field. + /// + public bool ShowChasseAuxLivresLink { get; set; } + + /// + /// Shows an "open on Amazon" link next to an owned copy's Reference field whenever it was created by + /// the Amazon order-history import (i.e. its Reference text carries an ASIN). + /// + public bool ShowAmazonProductLink { get; set; } +} diff --git a/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs b/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs index 2fb26739..414b602e 100644 --- a/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/UserPreferencesResourceTest.cs @@ -30,7 +30,7 @@ public async Task Get_ReturnsAllFalseDefaults_WhenNothingWasEverSaved() var preferences = await GetAsync($"/{ResourceEndpoint}"); - preferences.ShowChasseAuxLivresLink.Should().BeFalse(); + preferences.Features.ShowChasseAuxLivresLink.Should().BeFalse(); } [Fact] @@ -38,13 +38,13 @@ public async Task Put_ThenGet_RoundTripsTheSavedValue() { await Authenticate(); - await PutAsync($"/{ResourceEndpoint}", new UserPreferencesDto { ShowChasseAuxLivresLink = true }); + await PutAsync($"/{ResourceEndpoint}", new UserPreferencesDto { Features = new UserPreferencesFeaturesDto { ShowChasseAuxLivresLink = true } }); var afterFirstSave = await GetAsync($"/{ResourceEndpoint}"); - afterFirstSave.ShowChasseAuxLivresLink.Should().BeTrue(); + afterFirstSave.Features.ShowChasseAuxLivresLink.Should().BeTrue(); // a second PUT must update the same document (upsert by owner_id), not collide with the unique index - await PutAsync($"/{ResourceEndpoint}", new UserPreferencesDto { ShowChasseAuxLivresLink = false }); + await PutAsync($"/{ResourceEndpoint}", new UserPreferencesDto { Features = new UserPreferencesFeaturesDto { ShowChasseAuxLivresLink = false } }); var afterSecondSave = await GetAsync($"/{ResourceEndpoint}"); - afterSecondSave.ShowChasseAuxLivresLink.Should().BeFalse(); + afterSecondSave.Features.ShowChasseAuxLivresLink.Should().BeFalse(); } } diff --git a/test/WebApi.UnitTests/Services/AmazonReferenceTest.cs b/test/WebApi.UnitTests/Services/AmazonReferenceTest.cs new file mode 100644 index 00000000..71844af7 --- /dev/null +++ b/test/WebApi.UnitTests/Services/AmazonReferenceTest.cs @@ -0,0 +1,49 @@ +using AwesomeAssertions; +using Keeptrack.Common.System; +using Keeptrack.Domain.Services; +using Xunit; + +namespace Keeptrack.WebApi.UnitTests.Services; + +/// +/// is the read side of 's +/// write side - covers both the round trip between them and the parts unique to the read side (vendor domain). +/// +[Trait("Category", "UnitTests")] +public class AmazonReferenceTest +{ + [Fact] + public void TryExtractAsin_RoundTrips_WhatFormatOrderReferenceWrote() + { + var reference = AmazonImportMergeService.FormatOrderReference("405-1111111-1111111", "0552177571"); + + AmazonReference.TryExtractAsin(reference).Should().Be("0552177571"); + } + + [Fact] + public void TryExtractAsin_ReturnsNull_ForAManuallyEnteredReferenceWithNoAsin() + { + AmazonReference.TryExtractAsin("Special edition, box 3").Should().BeNull(); + } + + [Fact] + public void TryExtractAsin_ReturnsNull_ForNullOrEmptyReference() + { + AmazonReference.TryExtractAsin(null).Should().BeNull(); + AmazonReference.TryExtractAsin("").Should().BeNull(); + } + + [Fact] + public void BuildProductUrl_UsesTheVendorsOwnAmazonDomain_WhenItLooksLikeOne() + { + AmazonReference.BuildProductUrl("0552177571", "Amazon.fr").Should().Be("https://amazon.fr/dp/0552177571"); + AmazonReference.BuildProductUrl("0552177571", "www.amazon.de").Should().Be("https://www.amazon.de/dp/0552177571"); + } + + [Fact] + public void BuildProductUrl_FallsBackToAmazonFr_WhenTheVendorIsntRecognizablyAmazon() + { + AmazonReference.BuildProductUrl("0552177571", "Fnac").Should().Be("https://www.amazon.fr/dp/0552177571"); + AmazonReference.BuildProductUrl("0552177571", null).Should().Be("https://www.amazon.fr/dp/0552177571"); + } +} From 4e86b6f9b7c4552bca64b06c4e9fbe717435e360 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 23 Jul 2026 23:32:16 +0200 Subject: [PATCH 22/35] Remove the semicolon from comments --- .../Components/Inventory/Clients/InventoryApiClientBase.cs | 2 +- src/WebApi/Controllers/SystemStatusController.cs | 2 +- src/WebApi/Import/CarHistoryImportService.cs | 2 +- src/WebApi/Program.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs index b0f30496..78b869eb 100644 --- a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs +++ b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs @@ -42,7 +42,7 @@ public async Task AddAsync(TDto movie) var response = await http.PostAsJsonAsync($"{ApiResourceName}", movie); if (!response.IsSuccessStatusCode) { - // the API returns error bodies (free-tier quota 403s, ApiExceptionFilterAttribute's 400s/500s); + // the API returns error bodies (free-tier quota 403s, ApiExceptionFilterAttribute's 400s/500s) - // surfacing that text beats EnsureSuccessStatusCode's opaque "403 (Forbidden)" in the Add form var body = await response.Content.ReadFromJsonAsync(); throw new InvalidOperationException(string.IsNullOrEmpty(body?.Error) diff --git a/src/WebApi/Controllers/SystemStatusController.cs b/src/WebApi/Controllers/SystemStatusController.cs index e497e86d..d0f55008 100644 --- a/src/WebApi/Controllers/SystemStatusController.cs +++ b/src/WebApi/Controllers/SystemStatusController.cs @@ -41,7 +41,7 @@ public async Task> Get() { InstanceName = Environment.MachineName, IsReferenceSyncEnabled = appConfiguration.IsReferenceSyncEnabled, - // the *default* provider used for automatic/background resolution - see BookReferenceClientRegistry; + // the *default* provider used for automatic/background resolution - see BookReferenceClientRegistry. // an admin can search/link with any registered provider regardless of this value (GET book-providers) BookProvider = string.IsNullOrEmpty(appConfiguration.BookReferenceProvider) ? "googlebooks" : appConfiguration.BookReferenceProvider, ReferenceSyncLease = lease is null diff --git a/src/WebApi/Import/CarHistoryImportService.cs b/src/WebApi/Import/CarHistoryImportService.cs index ff1ff380..ef1e5cb6 100644 --- a/src/WebApi/Import/CarHistoryImportService.cs +++ b/src/WebApi/Import/CarHistoryImportService.cs @@ -174,7 +174,7 @@ private static Dictionary ReadHeaders(IXLWorksheet sheet) var headers = new Dictionary(); foreach (var cell in headerRow.CellsUsed()) { - // Some headers (e.g. "Autoroute\nEloigné") span two lines within a single cell; + // Some headers (e.g. "Autoroute\nEloigné") span two lines within a single cell - // normalize whitespace so a lookup doesn't have to guess the exact line-break character used in the file. var text = string.Join(' ', cell.GetString().Split([' ', '\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries)); if (text.Length > 0) headers.TryAdd(text, cell.Address.ColumnNumber); diff --git a/src/WebApi/Program.cs b/src/WebApi/Program.cs index cadb92b9..d37048a6 100644 --- a/src/WebApi/Program.cs +++ b/src/WebApi/Program.cs @@ -128,7 +128,7 @@ builder.Services.AddAuthorization(options => { options.AddPolicy("AdminOnly", policy => policy.RequireClaim("role", "admin")); - // members (and admins - a membership must never be *less* than the owner's own account) get the full app; + // members (and admins - a membership must never be *less* than the owner's own account) get the full app. // authenticated users without the role are the free preview tier (movies + TV shows, capped - see DataCrudControllerBase). // Granted the same way as admin: a Firebase custom claim role=member via the Admin SDK (see CONTRIBUTING.md). options.AddPolicy("MemberOnly", policy => policy.RequireClaim("role", "member", "admin")); From 269ce822b67e8425d64fcb5dc9e9b2b11e2dd956 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 24 Jul 2026 00:12:36 +0200 Subject: [PATCH 23/35] Fix Sonar findings: validation loops, string literals, nested ternaries, date parsing, regex timeouts, cognitive complexity - Replace throw-on-first-match foreach loops with FirstOrDefault+throw (S1751) - Extract repeated string literals to constants (S1192) - Simplify Count-1 indexing and a synchronous filter loop to LINQ (S6608, S3267) - Deduplicate the identical refresh-reference nested ternary across five detail pages into ReferenceRefreshMessage.Compute, and the identical DateOnly HTML-input parsing across eight sites into DateOnlyInput.Parse - both fix S3358/S6580 while removing the underlying duplication - Add a match timeout to every Regex construction/replace missing one (S6444) - Extract per-row/per-item helper methods in HealthImportService and OwnedItemImportMergeService to bring cognitive complexity back under threshold (S3776), verified against existing unit tests plus a new DateOnlyInputTest S8970 (null-forgiving operator removal) and the S5332 http:// BnfClient XML namespace URIs were investigated and left alone - both are Sonar false positives confirmed by a real compiler error and by XML namespace semantics respectively. S107/S6960 (parameter counts, "split this controller") are architectural, matching this codebase's documented DI-heavy/one-controller- per-feature conventions, so left as-is. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01E91RS1SmtWz2CBqk2YbEby --- .../Inventory/Pages/AlbumDetail.razor | 4 +- .../Inventory/Pages/BookDetail.razor | 6 +- .../Inventory/Pages/HouseDetail.razor | 4 +- .../Inventory/Pages/MovieDetail.razor | 6 +- .../Inventory/Pages/TvShowDetail.razor | 4 +- .../Inventory/Pages/VideoGameDetail.razor | 10 +- .../Inventory/Shared/OwnedVersionFields.razor | 2 +- .../Components/Shared/DateOnlyInput.cs | 15 +++ .../Shared/ReferenceRefreshMessage.cs | 23 ++++ .../Components/Shared/SvgChartHelpers.cs | 47 ++++---- .../Services/OwnedItemImportMergeService.cs | 102 ++++++++++------ .../Controllers/AmazonImportController.cs | 10 +- .../GenericVideoGameImportController.cs | 5 +- src/WebApi/Import/HealthImportService.cs | 110 ++++++++++-------- src/WebApi/ReferenceData/BnfClient.cs | 8 +- src/WebApi/ReferenceData/OpenLibraryClient.cs | 6 +- .../ReferenceEnrichmentService.Albums.cs | 12 +- .../Components/Shared/DateOnlyInputTest.cs | 25 ++++ 18 files changed, 255 insertions(+), 144 deletions(-) create mode 100644 src/BlazorApp/Components/Shared/DateOnlyInput.cs create mode 100644 src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs create mode 100644 test/BlazorApp.UnitTests/Components/Shared/DateOnlyInputTest.cs diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor index 08e524a0..81582bf8 100644 --- a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor @@ -307,9 +307,7 @@ else var refreshed = await AlbumApi.RefreshReferenceAsync(Album.Id); await LoadAsync(); - (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) - ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") - : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + (_refreshMessage, _refreshMessageStyle) = ReferenceRefreshMessage.Compute(previousReferenceId, refreshed.ReferenceId); } catch (Exception ex) { diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index a9d4c17c..6c4ee4bc 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -250,7 +250,7 @@ else private async Task SetReadDateAsync(ChangeEventArgs e) { if (Book is null) return; - Book.FirstReadAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + Book.FirstReadAt = DateOnlyInput.Parse(e.Value); await BookApi.UpdateAsync(Book); } @@ -319,9 +319,7 @@ else var refreshed = await BookApi.RefreshReferenceAsync(Book.Id); await LoadAsync(); - (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) - ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") - : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + (_refreshMessage, _refreshMessageStyle) = ReferenceRefreshMessage.Compute(previousReferenceId, refreshed.ReferenceId); } catch (Exception ex) { diff --git a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor index bcc70d80..a9ae3206 100644 --- a/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/HouseDetail.razor @@ -317,14 +317,14 @@ else private async Task SaveMovedInAtAsync(ChangeEventArgs e) { if (House is null) return; - House.MovedInAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + House.MovedInAt = DateOnlyInput.Parse(e.Value); await SaveHouseAsync(); } private async Task SaveMovedOutAtAsync(ChangeEventArgs e) { if (House is null) return; - House.MovedOutAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + House.MovedOutAt = DateOnlyInput.Parse(e.Value); await SaveHouseAsync(); } diff --git a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor index 224d5326..76c8a5a0 100644 --- a/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/MovieDetail.razor @@ -225,7 +225,7 @@ else private async Task SetWatchedDateAsync(ChangeEventArgs e) { if (Movie is null) return; - Movie.FirstSeenAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + Movie.FirstSeenAt = DateOnlyInput.Parse(e.Value); await MovieApi.UpdateAsync(Movie); } @@ -250,9 +250,7 @@ else var refreshed = await MovieApi.RefreshReferenceAsync(Movie.Id); await LoadAsync(); - (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) - ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") - : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + (_refreshMessage, _refreshMessageStyle) = ReferenceRefreshMessage.Compute(previousReferenceId, refreshed.ReferenceId); } catch (Exception ex) { diff --git a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor index 009b75a0..917d37db 100644 --- a/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/TvShowDetail.razor @@ -373,9 +373,7 @@ else var refreshed = await TvShowApi.RefreshReferenceAsync(_show.Id); await LoadAsync(); - (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) - ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") - : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + (_refreshMessage, _refreshMessageStyle) = ReferenceRefreshMessage.Compute(previousReferenceId, refreshed.ReferenceId); } catch (Exception ex) { diff --git a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor index 3199cbb0..4455998f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/VideoGameDetail.razor @@ -389,7 +389,7 @@ else private async Task SetPlatformCompletedDateAsync(VideoGamePlatformDto entry, ChangeEventArgs e) { - entry.CompletedAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + entry.CompletedAt = DateOnlyInput.Parse(e.Value); await SaveGameAsync(); } @@ -408,7 +408,7 @@ else private async Task SetFullyCompletedDateAsync(VideoGamePlatformDto entry, ChangeEventArgs e) { - entry.FullyCompletedAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + entry.FullyCompletedAt = DateOnlyInput.Parse(e.Value); await SaveGameAsync(); } @@ -432,7 +432,7 @@ else private async Task UpdatePlaythroughDateAsync(PlaythroughDto playthrough, ChangeEventArgs e) { - playthrough.CompletedAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + playthrough.CompletedAt = DateOnlyInput.Parse(e.Value); await SaveGameAsync(); } @@ -448,9 +448,7 @@ else var refreshed = await VideoGameApi.RefreshReferenceAsync(Game.Id); await LoadAsync(); - (_refreshMessage, _refreshMessageStyle) = string.IsNullOrEmpty(refreshed.ReferenceId) - ? string.IsNullOrEmpty(previousReferenceId) ? ("No match found", "neutral") : ("Unlinked - no match", "danger") - : refreshed.ReferenceId == previousReferenceId ? ("Already linked", "neutral") : ("Linked!", "success"); + (_refreshMessage, _refreshMessageStyle) = ReferenceRefreshMessage.Compute(previousReferenceId, refreshed.ReferenceId); } catch (Exception ex) { diff --git a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor index 5799f4f4..eb816096 100644 --- a/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor +++ b/src/BlazorApp/Components/Inventory/Shared/OwnedVersionFields.razor @@ -92,7 +92,7 @@ private Task SetAcquiredAtAsync(ChangeEventArgs e) { - Copy.AcquiredAt = DateOnly.TryParse(e.Value?.ToString(), out var date) ? date : null; + Copy.AcquiredAt = DateOnlyInput.Parse(e.Value); return OnChanged.InvokeAsync(); } diff --git a/src/BlazorApp/Components/Shared/DateOnlyInput.cs b/src/BlazorApp/Components/Shared/DateOnlyInput.cs new file mode 100644 index 00000000..4e6d766a --- /dev/null +++ b/src/BlazorApp/Components/Shared/DateOnlyInput.cs @@ -0,0 +1,15 @@ +using System.Globalization; + +namespace Keeptrack.BlazorApp.Components.Shared; + +/// +/// Shared parser for a plain HTML date input's onchange value - always "yyyy-MM-dd" per the HTML spec +/// regardless of the browser's locale, so this always parses with the invariant culture rather than the +/// current one. Used by every owned-copy/car-history/house-history/video-game-completion-style bound +/// ? field instead of duplicating the same TryParse ternary at each call site. +/// +public static class DateOnlyInput +{ + public static DateOnly? Parse(object? value) => + DateOnly.TryParse(value?.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null; +} diff --git a/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs b/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs new file mode 100644 index 00000000..d4d5facf --- /dev/null +++ b/src/BlazorApp/Components/Shared/ReferenceRefreshMessage.cs @@ -0,0 +1,23 @@ +namespace Keeptrack.BlazorApp.Components.Shared; + +/// +/// Shared "check for reference match" result message for every reference-linked detail page's refresh-reference +/// button (Movie/TvShow/Book/Album/VideoGame) - keeps the message/style rule in one place instead of duplicating +/// the same four-way outcome across five detail pages. +/// +public static class ReferenceRefreshMessage +{ + public static (string Message, string Style) Compute(string? previousReferenceId, string? newReferenceId) + { + if (string.IsNullOrEmpty(newReferenceId)) + { + return string.IsNullOrEmpty(previousReferenceId) + ? ("No match found", "neutral") + : ("Unlinked - no match", "danger"); + } + + return newReferenceId == previousReferenceId + ? ("Already linked", "neutral") + : ("Linked!", "success"); + } +} diff --git a/src/BlazorApp/Components/Shared/SvgChartHelpers.cs b/src/BlazorApp/Components/Shared/SvgChartHelpers.cs index 27d2a5b2..663f11e3 100644 --- a/src/BlazorApp/Components/Shared/SvgChartHelpers.cs +++ b/src/BlazorApp/Components/Shared/SvgChartHelpers.cs @@ -29,6 +29,13 @@ public readonly record struct ChartGeometry( public static readonly ChartGeometry FullWidthGeometry = new(ViewWidth: 600, ViewHeight: 170, PlotLeft: 40, PlotRight: 588, PlotTop: 14, PlotBottom: 132); + private const string AttrStroke = "stroke"; + private const string AttrStrokeWidth = "stroke-width"; + private const string AttrVectorEffect = "vector-effect"; + private const string NonScalingStroke = "non-scaling-stroke"; + private const string AttrTextAnchor = "text-anchor"; + private const string AttrClass = "class"; + /// /// Draws a graduated X/Y axis pair (arrowhead, tick marks, tick labels, axis title). /// Ticks are computed by the caller, since what counts as an evenly-spaced value differs between a continuous line chart and a per-bar categorical one. @@ -65,9 +72,9 @@ public static void RenderAxes( builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); builder.AddAttribute(seq++, "x2", plotLeft.ToString("F1")); builder.AddAttribute(seq++, "y2", plotTop.ToString("F1")); - builder.AddAttribute(seq++, "stroke", AxisColor); - builder.AddAttribute(seq++, "stroke-width", "1"); - builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.AddAttribute(seq++, AttrStroke, AxisColor); + builder.AddAttribute(seq++, AttrStrokeWidth, "1"); + builder.AddAttribute(seq++, AttrVectorEffect, NonScalingStroke); builder.AddAttribute(seq++, "marker-end", $"url(#{markerId})"); builder.CloseElement(); @@ -77,9 +84,9 @@ public static void RenderAxes( builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); builder.AddAttribute(seq++, "x2", plotRight.ToString("F1")); builder.AddAttribute(seq++, "y2", plotBottom.ToString("F1")); - builder.AddAttribute(seq++, "stroke", AxisColor); - builder.AddAttribute(seq++, "stroke-width", "1"); - builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.AddAttribute(seq++, AttrStroke, AxisColor); + builder.AddAttribute(seq++, AttrStrokeWidth, "1"); + builder.AddAttribute(seq++, AttrVectorEffect, NonScalingStroke); builder.AddAttribute(seq++, "marker-end", $"url(#{markerId})"); builder.CloseElement(); @@ -90,16 +97,16 @@ public static void RenderAxes( builder.AddAttribute(seq++, "y1", y.ToString("F1")); builder.AddAttribute(seq++, "x2", plotLeft.ToString("F1")); builder.AddAttribute(seq++, "y2", y.ToString("F1")); - builder.AddAttribute(seq++, "stroke", AxisColor); - builder.AddAttribute(seq++, "stroke-width", "1"); - builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.AddAttribute(seq++, AttrStroke, AxisColor); + builder.AddAttribute(seq++, AttrStrokeWidth, "1"); + builder.AddAttribute(seq++, AttrVectorEffect, NonScalingStroke); builder.CloseElement(); builder.OpenElement(seq++, "text"); builder.AddAttribute(seq++, "x", (plotLeft - 5).ToString("F1")); builder.AddAttribute(seq++, "y", (y + 2.5).ToString("F1")); - builder.AddAttribute(seq++, "text-anchor", "end"); - builder.AddAttribute(seq++, "class", "kt-chart-axis-text"); + builder.AddAttribute(seq++, AttrTextAnchor, "end"); + builder.AddAttribute(seq++, AttrClass, "kt-chart-axis-text"); builder.AddContent(seq++, label); builder.CloseElement(); } @@ -111,16 +118,16 @@ public static void RenderAxes( builder.AddAttribute(seq++, "y1", plotBottom.ToString("F1")); builder.AddAttribute(seq++, "x2", x.ToString("F1")); builder.AddAttribute(seq++, "y2", (plotBottom + 3).ToString("F1")); - builder.AddAttribute(seq++, "stroke", AxisColor); - builder.AddAttribute(seq++, "stroke-width", "1"); - builder.AddAttribute(seq++, "vector-effect", "non-scaling-stroke"); + builder.AddAttribute(seq++, AttrStroke, AxisColor); + builder.AddAttribute(seq++, AttrStrokeWidth, "1"); + builder.AddAttribute(seq++, AttrVectorEffect, NonScalingStroke); builder.CloseElement(); builder.OpenElement(seq++, "text"); builder.AddAttribute(seq++, "x", x.ToString("F1")); builder.AddAttribute(seq++, "y", (plotBottom + 12).ToString("F1")); - builder.AddAttribute(seq++, "text-anchor", "middle"); - builder.AddAttribute(seq++, "class", "kt-chart-axis-text"); + builder.AddAttribute(seq++, AttrTextAnchor, "middle"); + builder.AddAttribute(seq++, AttrClass, "kt-chart-axis-text"); builder.AddContent(seq++, label); builder.CloseElement(); } @@ -129,8 +136,8 @@ public static void RenderAxes( builder.OpenElement(seq++, "text"); builder.AddAttribute(seq++, "x", "2"); builder.AddAttribute(seq++, "y", (plotTop - 4).ToString("F1")); - builder.AddAttribute(seq++, "text-anchor", "start"); - builder.AddAttribute(seq++, "class", "kt-chart-axis-title"); + builder.AddAttribute(seq++, AttrTextAnchor, "start"); + builder.AddAttribute(seq++, AttrClass, "kt-chart-axis-title"); builder.AddContent(seq++, yAxisLabel); builder.CloseElement(); @@ -138,8 +145,8 @@ public static void RenderAxes( builder.OpenElement(seq++, "text"); builder.AddAttribute(seq++, "x", xTitleCenter.ToString("F1")); builder.AddAttribute(seq++, "y", (viewHeight - 4).ToString("F1")); - builder.AddAttribute(seq++, "text-anchor", "middle"); - builder.AddAttribute(seq++, "class", "kt-chart-axis-title"); + builder.AddAttribute(seq++, AttrTextAnchor, "middle"); + builder.AddAttribute(seq++, AttrClass, "kt-chart-axis-title"); builder.AddContent(seq++, xAxisLabel); builder.CloseElement(); } diff --git a/src/Domain/Services/OwnedItemImportMergeService.cs b/src/Domain/Services/OwnedItemImportMergeService.cs index 3f5c889b..89f70485 100644 --- a/src/Domain/Services/OwnedItemImportMergeService.cs +++ b/src/Domain/Services/OwnedItemImportMergeService.cs @@ -65,14 +65,7 @@ public static ImportCommitPlan ComputeCommitPlan( var byNormalizedTitle = new Dictionary(); var byReference = new Dictionary(); - foreach (var existing in existingItems) - { - byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(getExistingTitle(existing)), existing); - foreach (var reference in getExistingReferences(existing)) - { - if (reference is not null) byReference.TryAdd(reference, existing); - } - } + IndexExistingItems(existingItems, getExistingTitle, getExistingReferences, byNormalizedTitle, byReference); // Items created earlier in this same batch are tracked separately from pre-existing ones: a later // row matching one must only get its owned copy appended (already reflected via ItemsToCreate), @@ -81,46 +74,79 @@ public static ImportCommitPlan ComputeCommitPlan( foreach (var item in items) { - var reference = getItemReference(item); - if (reference is not null && byReference.ContainsKey(reference)) + MergeItem(item, plan, byNormalizedTitle, byReference, createdThisBatch, getItemTitle, getItemReference, createNew, appendOwnedCopy); + } + + return plan; + } + + private static void IndexExistingItems( + IReadOnlyCollection existingItems, + Func getExistingTitle, + Func> getExistingReferences, + Dictionary byNormalizedTitle, + Dictionary byReference) + where TModel : class + { + foreach (var existing in existingItems) + { + byNormalizedTitle.TryAdd(TitleNormalizer.Normalize(getExistingTitle(existing)), existing); + foreach (var reference in getExistingReferences(existing).OfType()) { - plan.OwnedCopiesSkipped++; - plan.SkippedTitles.Add(getItemTitle(item)); - continue; + byReference.TryAdd(reference, existing); } + } + } - var key = TitleNormalizer.Normalize(getItemTitle(item)); - TModel target; + private static void MergeItem( + TRequestItem item, + ImportCommitPlan plan, + Dictionary byNormalizedTitle, + Dictionary byReference, + HashSet createdThisBatch, + Func getItemTitle, + Func getItemReference, + Func createNew, + Action appendOwnedCopy) + where TModel : class + { + var reference = getItemReference(item); + if (reference is not null && byReference.ContainsKey(reference)) + { + plan.OwnedCopiesSkipped++; + plan.SkippedTitles.Add(getItemTitle(item)); + return; + } - if (byNormalizedTitle.TryGetValue(key, out var existing)) - { - appendOwnedCopy(existing, item); - if (!createdThisBatch.Contains(existing) && !plan.ItemsToUpdate.Contains(existing)) - { - plan.ItemsToUpdate.Add(existing); - } + var key = TitleNormalizer.Normalize(getItemTitle(item)); + TModel target; - target = existing; - } - else + if (byNormalizedTitle.TryGetValue(key, out var existing)) + { + appendOwnedCopy(existing, item); + if (!createdThisBatch.Contains(existing) && !plan.ItemsToUpdate.Contains(existing)) { - var created = createNew(item); - plan.ItemsToCreate.Add(created); - createdThisBatch.Add(created); - - // so a later row in the same batch sharing this title merges into it too, instead of - // creating a second item - byNormalizedTitle[key] = created; - target = created; + plan.ItemsToUpdate.Add(existing); } - // registers this reference immediately (not just from the initial existingItems scan), so a - // second row in the same batch carrying the same reference is caught by the check above too - if (reference is not null) byReference[reference] = target; + target = existing; + } + else + { + var created = createNew(item); + plan.ItemsToCreate.Add(created); + createdThisBatch.Add(created); - plan.OwnedCopiesAdded++; + // so a later row in the same batch sharing this title merges into it too, instead of + // creating a second item + byNormalizedTitle[key] = created; + target = created; } - return plan; + // registers this reference immediately (not just from the initial existingItems scan), so a + // second row in the same batch carrying the same reference is caught by the check above too + if (reference is not null) byReference[reference] = target; + + plan.OwnedCopiesAdded++; } } diff --git a/src/WebApi/Controllers/AmazonImportController.cs b/src/WebApi/Controllers/AmazonImportController.cs index fd9acf42..0c88ba58 100644 --- a/src/WebApi/Controllers/AmazonImportController.cs +++ b/src/WebApi/Controllers/AmazonImportController.cs @@ -86,9 +86,10 @@ public async Task> Commit(AmazonImport var ownerId = this.GetUserId(); var result = new AmazonImportCommitResultDto(); - foreach (var item in request.Items.Where(item => item.MediaType is null)) + var itemMissingMediaType = request.Items.FirstOrDefault(item => item.MediaType is null); + if (itemMissingMediaType is not null) { - throw new ArgumentException($"A media type is required to import '{item.Title}'."); + throw new ArgumentException($"A media type is required to import '{itemMissingMediaType.Title}'."); } var bookItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Book).ToList(); @@ -98,9 +99,10 @@ public async Task> Commit(AmazonImport var gearItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Gear).ToList(); var collectibleItems = request.Items.Where(i => i.MediaType == AmazonImportMediaType.Collectible).ToList(); - foreach (var item in videoGameItems.Where(item => string.IsNullOrWhiteSpace(item.Platform))) + var videoGameItemMissingPlatform = videoGameItems.FirstOrDefault(item => string.IsNullOrWhiteSpace(item.Platform)); + if (videoGameItemMissingPlatform is not null) { - throw new ArgumentException($"A platform is required to import '{item.Title}' as a video game."); + throw new ArgumentException($"A platform is required to import '{videoGameItemMissingPlatform.Title}' as a video game."); } if (bookItems.Count > 0) diff --git a/src/WebApi/Controllers/GenericVideoGameImportController.cs b/src/WebApi/Controllers/GenericVideoGameImportController.cs index c63ff3a3..c9be506f 100644 --- a/src/WebApi/Controllers/GenericVideoGameImportController.cs +++ b/src/WebApi/Controllers/GenericVideoGameImportController.cs @@ -65,9 +65,10 @@ public async Task> Commit(Ge { var ownerId = this.GetUserId(); - foreach (var item in request.Items.Where(item => string.IsNullOrWhiteSpace(item.Platform))) + var itemMissingPlatform = request.Items.FirstOrDefault(item => string.IsNullOrWhiteSpace(item.Platform)); + if (itemMissingPlatform is not null) { - throw new ArgumentException($"A platform is required to import '{item.Title}'."); + throw new ArgumentException($"A platform is required to import '{itemMissingPlatform.Title}'."); } var existingVideoGames = await FindAllAsync(ownerId); diff --git a/src/WebApi/Import/HealthImportService.cs b/src/WebApi/Import/HealthImportService.cs index de5aef38..9a733dfa 100644 --- a/src/WebApi/Import/HealthImportService.cs +++ b/src/WebApi/Import/HealthImportService.cs @@ -78,58 +78,74 @@ public async Task ImportAsync(Stream xlsxStream, string o foreach (var row in sheet.RowsUsed().Skip(1)) { - var dateCell = row.Cell(columns.Date.Value); - if (dateCell.IsEmpty()) continue; - if (!dateCell.TryGetValue(out DateTime date)) - { - result.Warnings.Add($"Row {row.RowNumber()}: skipped, unreadable date (\"{dateCell.GetString()}\")."); - continue; - } - - var personName = ExcelCellParser.StringOrNull(Cell(row, columns.Person)); - if (personName is null) - { - result.Warnings.Add($"Row {row.RowNumber()}: skipped, no \"Personne\" to attach the entry to."); - continue; - } + await ImportRowAsync(row, columns, ownerId, profilesByName, result); + } - if (!profilesByName.TryGetValue(personName, out var target)) - { - target = await healthProfileRepository.CreateAsync(new HealthProfileModel { OwnerId = ownerId, Name = personName }); - profilesByName[personName] = target; - result.ProfilesCreated++; - } + result.ProfilesSkipped = profilesByName.Count - result.ProfilesCreated; + return result; + } - var time = ExcelCellParser.ParseTime(Cell(row, columns.Time)) ?? TimeOnly.MinValue; + private async Task ImportRowAsync(IXLRow row, SheetColumns columns, string ownerId, + Dictionary profilesByName, HealthImportResultDto result) + { + var dateCell = row.Cell(columns.Date!.Value); + if (dateCell.IsEmpty()) return; + if (!dateCell.TryGetValue(out DateTime date)) + { + result.Warnings.Add($"Row {row.RowNumber()}: skipped, unreadable date (\"{dateCell.GetString()}\")."); + return; + } - await healthRecordRepository.CreateAsync(new HealthRecordModel - { - OwnerId = ownerId, - HealthProfileId = target.Id!, - HistoryDate = DateOnly.FromDateTime(date).ToDateTime(time), - // every row in this journal is a care event; sicknesses weren't tracked in the spreadsheet - EventType = HealthEventType.Appointment, - Specialty = ExcelCellParser.StringOrNull(Cell(row, columns.Specialty)), - Practitioner = ExcelCellParser.StringOrNull(Cell(row, columns.Practitioner)), - Description = ExcelCellParser.StringOrNull(Cell(row, columns.Description)), - Price = ExcelCellParser.PriceOrNull(Cell(row, columns.Paid)), - PublicReimbursement = ExcelCellParser.PriceOrNull(Cell(row, columns.PublicReimbursement)), - InsuranceReimbursement = ExcelCellParser.PriceOrNull(Cell(row, columns.InsuranceReimbursement)), - // the sparse bookkeeping columns (place, actual ameli bank transfer, payment dates, comment) have no dedicated fields - - // preserved as labeled notes instead of dropped - Notes = ExcelCellParser.JoinNonEmpty("; ", - ExcelCellParser.StringOrNull(Cell(row, columns.Notes)), - ExcelCellParser.StringOrNull(Cell(row, columns.Location)) is { } location ? $"Lieu : {location}" : null, - ExcelCellParser.PriceOrNull(Cell(row, columns.PublicTransfer)) is { } transfer ? $"Virement Ameli : {transfer:F2}" : null, - DateTextOrNull(Cell(row, columns.PublicDate)) is { } publicDate ? $"Payé Ameli le {publicDate}" : null, - DateTextOrNull(Cell(row, columns.InsuranceDate)) is { } insuranceDate ? $"Payé mutuelle le {insuranceDate}" : null, - ExcelCellParser.StringOrNull(Cell(row, columns.Comment))) - }); - result.RecordsCreated++; + var personName = ExcelCellParser.StringOrNull(Cell(row, columns.Person)); + if (personName is null) + { + result.Warnings.Add($"Row {row.RowNumber()}: skipped, no \"Personne\" to attach the entry to."); + return; } - result.ProfilesSkipped = profilesByName.Count - result.ProfilesCreated; - return result; + var target = await GetOrCreateProfileAsync(personName, ownerId, profilesByName, result); + await healthRecordRepository.CreateAsync(BuildRecord(row, columns, ownerId, target, date)); + result.RecordsCreated++; + } + + private async Task GetOrCreateProfileAsync(string personName, string ownerId, + Dictionary profilesByName, HealthImportResultDto result) + { + if (profilesByName.TryGetValue(personName, out var target)) return target; + + target = await healthProfileRepository.CreateAsync(new HealthProfileModel { OwnerId = ownerId, Name = personName }); + profilesByName[personName] = target; + result.ProfilesCreated++; + return target; + } + + private static HealthRecordModel BuildRecord(IXLRow row, SheetColumns columns, string ownerId, HealthProfileModel target, DateTime date) + { + var time = ExcelCellParser.ParseTime(Cell(row, columns.Time)) ?? TimeOnly.MinValue; + + return new HealthRecordModel + { + OwnerId = ownerId, + HealthProfileId = target.Id!, + HistoryDate = DateOnly.FromDateTime(date).ToDateTime(time), + // every row in this journal is a care event; sicknesses weren't tracked in the spreadsheet + EventType = HealthEventType.Appointment, + Specialty = ExcelCellParser.StringOrNull(Cell(row, columns.Specialty)), + Practitioner = ExcelCellParser.StringOrNull(Cell(row, columns.Practitioner)), + Description = ExcelCellParser.StringOrNull(Cell(row, columns.Description)), + Price = ExcelCellParser.PriceOrNull(Cell(row, columns.Paid)), + PublicReimbursement = ExcelCellParser.PriceOrNull(Cell(row, columns.PublicReimbursement)), + InsuranceReimbursement = ExcelCellParser.PriceOrNull(Cell(row, columns.InsuranceReimbursement)), + // the sparse bookkeeping columns (place, actual ameli bank transfer, payment dates, comment) have no dedicated fields - + // preserved as labeled notes instead of dropped + Notes = ExcelCellParser.JoinNonEmpty("; ", + ExcelCellParser.StringOrNull(Cell(row, columns.Notes)), + ExcelCellParser.StringOrNull(Cell(row, columns.Location)) is { } location ? $"Lieu : {location}" : null, + ExcelCellParser.PriceOrNull(Cell(row, columns.PublicTransfer)) is { } transfer ? $"Virement Ameli : {transfer:F2}" : null, + DateTextOrNull(Cell(row, columns.PublicDate)) is { } publicDate ? $"Payé Ameli le {publicDate}" : null, + DateTextOrNull(Cell(row, columns.InsuranceDate)) is { } insuranceDate ? $"Payé mutuelle le {insuranceDate}" : null, + ExcelCellParser.StringOrNull(Cell(row, columns.Comment))) + }; } private static SheetColumns ReadColumns(IXLWorksheet sheet) diff --git a/src/WebApi/ReferenceData/BnfClient.cs b/src/WebApi/ReferenceData/BnfClient.cs index b349e794..eea22499 100644 --- a/src/WebApi/ReferenceData/BnfClient.cs +++ b/src/WebApi/ReferenceData/BnfClient.cs @@ -26,6 +26,8 @@ public class BnfClient(HttpClient http) : IBookReferenceClient private const int MaxGenres = 5; + private static readonly TimeSpan s_regexTimeout = TimeSpan.FromSeconds(1); + /// /// is deliberately never sent to BnF as a query criterion, same choice /// makes (see its own doc comment) though for a different reason here: @@ -155,7 +157,7 @@ private static IEnumerable ParseRecords(string xml) /// URL, an ISBN as plain text "ISBN 2841142787" - confirmed against the real API) - the ARK is already /// captured separately via srw:recordIdentifier, so this only looks for the ISBN-prefixed one. /// - private static readonly Regex s_isbnRegex = new(@"^ISBN\s+(.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex s_isbnRegex = new(@"^ISBN\s+(.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled, s_regexTimeout); private static string? ExtractIsbn(IEnumerable identifiers) => identifiers @@ -175,7 +177,7 @@ private static IEnumerable ParseRecords(string xml) if (string.IsNullOrWhiteSpace(raw)) return null; var namePart = raw.Split(". ", 2)[0]; - namePart = Regex.Replace(namePart, @"\s*\([^)]*\)", "").Trim(); + namePart = Regex.Replace(namePart, @"\s*\([^)]*\)", "", RegexOptions.None, s_regexTimeout).Trim(); var parts = namePart.Split(", ", 2); return parts.Length == 2 ? $"{parts[1]} {parts[0]}".Trim() : namePart; @@ -186,7 +188,7 @@ private static IEnumerable ParseRecords(string xml) /// catalogue dates can carry uncertainty markers or ranges - a defensive last-4-digit-token extraction, /// same shape as 's own year parsing, rather than a bare int.Parse. /// - private static readonly Regex s_yearRegex = new(@"\b\d{4}\b", RegexOptions.Compiled); + private static readonly Regex s_yearRegex = new(@"\b\d{4}\b", RegexOptions.Compiled, s_regexTimeout); private static int? ParseYear(string? date) { diff --git a/src/WebApi/ReferenceData/OpenLibraryClient.cs b/src/WebApi/ReferenceData/OpenLibraryClient.cs index f7081576..20b5467d 100644 --- a/src/WebApi/ReferenceData/OpenLibraryClient.cs +++ b/src/WebApi/ReferenceData/OpenLibraryClient.cs @@ -17,6 +17,8 @@ public class OpenLibraryClient(HttpClient http) : IBookReferenceClient public string DisplayName => "Open Library"; + private static readonly TimeSpan s_regexTimeout = TimeSpan.FromSeconds(1); + /// /// Deliberately does NOT filter server-side by : /// Open Library's first_publish_year is the work's ORIGINAL publication year, @@ -68,7 +70,7 @@ private async Task> SearchBooksCoreAsync(string string? authorExternalId = null; if (!string.IsNullOrEmpty(authorKey)) { - authorExternalId = authorKey.Split('/').Last(); + authorExternalId = authorKey.Split('/')[^1]; var author = await http.GetFromJsonAsync($"{authorKey}.json", cancellationToken); authorName = author?.Name; } @@ -124,7 +126,7 @@ JsonValueKind.Object when description.Value.TryGetProperty("value", out var valu /// Fellowship of the Ring" (OL27513W). The year is the last such token, since it's always what trails /// the day/month when both are present, and the only token when the date is a bare year. /// - private static readonly Regex s_yearRegex = new(@"\b\d{4}\b", RegexOptions.Compiled); + private static readonly Regex s_yearRegex = new(@"\b\d{4}\b", RegexOptions.Compiled, s_regexTimeout); private static int? ParseYear(string? date) { diff --git a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs index 83a74490..418c6f0e 100644 --- a/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs +++ b/src/WebApi/ReferenceData/ReferenceEnrichmentService.Albums.cs @@ -5,6 +5,8 @@ namespace Keeptrack.WebApi.ReferenceData; public partial class ReferenceEnrichmentService { + private const string DiscogsProviderKey = "discogs"; + /// /// User-triggered "check for reference match" for albums - see /// for the full rationale (this is the same local-only, @@ -100,17 +102,17 @@ public async Task ResolveAlbumAsync(string title, int? year // see ResolveTvShowAsync's own comment - the title-only fallback (which reuses existing.Id for the // upsert) must not run when year is known but simply unconfirmed yet, or it risks overwriting an // unrelated same-titled reference document instead of just linking wrong - var existing = await albumReferenceRepository.FindByExternalIdAsync("discogs", externalId) + var existing = await albumReferenceRepository.FindByExternalIdAsync(DiscogsProviderKey, externalId) ?? (details.Artist is not null ? await albumReferenceRepository.FindByTitleYearAsync(title, year, details.Artist) : null); if (existing is null && year is null && details.Artist is not null) { existing = await albumReferenceRepository.FindByTitleAsync(title, details.Artist); } var externalIds = existing?.ExternalIds ?? new Dictionary(); - externalIds["discogs"] = externalId; + externalIds[DiscogsProviderKey] = externalId; var artistReferenceId = !string.IsNullOrEmpty(details.ArtistExternalId) - ? await ResolvePersonReferenceIdAsync("discogs", details.ArtistExternalId, details.Artist ?? "Unknown", null) + ? await ResolvePersonReferenceIdAsync(DiscogsProviderKey, details.ArtistExternalId, details.Artist ?? "Unknown", null) : existing?.ArtistReferenceId; var model = new AlbumReferenceModel @@ -142,7 +144,7 @@ public async Task ResolveAlbumAsync(string title, int? year /// public async Task<(AlbumReferenceModel Model, bool DataChanged)> RefreshAlbumReferenceAsync(AlbumReferenceModel reference, CancellationToken cancellationToken = default) { - var externalId = reference.ExternalIds.GetValueOrDefault("discogs"); + var externalId = reference.ExternalIds.GetValueOrDefault(DiscogsProviderKey); if (string.IsNullOrEmpty(externalId)) return (reference, false); var details = await discogsClient.GetAlbumDetailsAsync(externalId, cancellationToken); @@ -153,7 +155,7 @@ public async Task ResolveAlbumAsync(string title, int? year reference.Synopsis = details.Synopsis; if (!string.IsNullOrEmpty(details.ArtistExternalId)) { - reference.ArtistReferenceId = await ResolvePersonReferenceIdAsync("discogs", details.ArtistExternalId, details.Artist ?? "Unknown", null); + reference.ArtistReferenceId = await ResolvePersonReferenceIdAsync(DiscogsProviderKey, details.ArtistExternalId, details.Artist ?? "Unknown", null); } reference.Genres = details.Genres; reference.Tracks = MapTracks(details.Tracks); diff --git a/test/BlazorApp.UnitTests/Components/Shared/DateOnlyInputTest.cs b/test/BlazorApp.UnitTests/Components/Shared/DateOnlyInputTest.cs new file mode 100644 index 00000000..77645710 --- /dev/null +++ b/test/BlazorApp.UnitTests/Components/Shared/DateOnlyInputTest.cs @@ -0,0 +1,25 @@ +using System; +using AwesomeAssertions; +using Keeptrack.BlazorApp.Components.Shared; +using Xunit; + +namespace Keeptrack.BlazorApp.UnitTests.Components.Shared; + +[Trait("Category", "UnitTests")] +public class DateOnlyInputTest +{ + [Fact] + public void Parse_ParsesAnHtmlDateInputValue_RegardlessOfCurrentCulture() + { + DateOnlyInput.Parse("2026-07-23").Should().Be(new DateOnly(2026, 7, 23)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-a-date")] + public void Parse_ReturnsNull_ForMissingOrInvalidInput(object? value) + { + DateOnlyInput.Parse(value).Should().BeNull(); + } +} From 031fe91088856fc4db1cc5a65f044653bb5fbc95 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 24 Jul 2026 00:27:29 +0200 Subject: [PATCH 24/35] Improve amazon import on video game The commit button and its warning text now also account for selected video-game rows with no platform chosen, mirroring the existing "pick a type" guard, so the 400 case can no longer be triggered from the UI. --- src/BlazorApp/Components/Import/AmazonImportPage.razor | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/BlazorApp/Components/Import/AmazonImportPage.razor b/src/BlazorApp/Components/Import/AmazonImportPage.razor index 5b40492a..f99d9b20 100644 --- a/src/BlazorApp/Components/Import/AmazonImportPage.razor +++ b/src/BlazorApp/Components/Import/AmazonImportPage.razor @@ -127,7 +127,12 @@

Pick a type for every selected row before importing.

} - @@ -175,6 +180,8 @@ private IEnumerable SelectedRows => _rows is null ? [] : _rows.Where(r => r.Selected); + private static bool IsMissingPlatform(EditableRow row) => row.MediaType == AmazonImportMediaType.VideoGame && string.IsNullOrEmpty(row.Platform); + /// Drives the header checkbox: checked only once every currently-shown row is selected. private bool AllDisplayedSelected => DisplayedRows.Any() && DisplayedRows.All(r => r.Selected); From 05ff55ff0500b9683834a6653e495a813215b2cf Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 24 Jul 2026 00:53:28 +0200 Subject: [PATCH 25/35] Improve image display for gear and collectible --- .../Inventory/Pages/CollectibleDetail.razor | 4 ++-- .../Inventory/Pages/GearDetail.razor | 4 ++-- src/BlazorApp/wwwroot/app.css | 22 +++++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor index d2857099..4bdb235f 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor @@ -33,8 +33,8 @@ else @if (!string.IsNullOrEmpty(Collectible.ImageUrl)) { -
- @Collectible.Title cover +
+ @Collectible.Title
} diff --git a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor index bf4d70db..ce069cd5 100644 --- a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor @@ -33,8 +33,8 @@ else @if (!string.IsNullOrEmpty(Item.ImageUrl)) { -
- @Item.Title cover +
+ @Item.Title
} diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index ba38720b..4330246a 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -601,6 +601,28 @@ a.kt-item-row { color: inherit; text-decoration: none; } .kt-album-cover { width: 200px; height: 200px; } } +/* Gear/Collectible detail cover: full-width, on top of the fields (unlike .kt-album-hero's side-by-side + layout) - sized and fitted for e-shop-style product photography (e.g. an Amazon listing photo), which is + usually a product shot on a plain background rather than a poster/album-art image with a fixed aspect + ratio. "contain" (not "cover") so the product is never cropped. No background/border - just the image, + centered and capped to a sensible height. */ +.kt-product-cover-box { + width: 100%; + height: 480px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.kt-product-cover-box img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} +@media (max-width: 767px) { + .kt-product-cover-box { height: 320px; } +} + /* compact aligned row for a list of tracks/songs inside a .kt-form-card (e.g. a playlist's song list) - a CSS grid so title/artist/album line up in columns across rows without resorting to a full .kt-table-wrap/, which reads as a top-level list page rather than a detail-page sub-section. */ From 6af00b55fdc0d36788a253b30b7b965779735f15 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 24 Jul 2026 01:06:30 +0200 Subject: [PATCH 26/35] Fix tests --- .../Components/Inventory/Pages/CollectibleDetail.razor | 2 +- src/BlazorApp/Components/Inventory/Pages/GearDetail.razor | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor index 4bdb235f..708d0634 100644 --- a/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/CollectibleDetail.razor @@ -34,7 +34,7 @@ else @if (!string.IsNullOrEmpty(Collectible.ImageUrl)) {
- @Collectible.Title + @Collectible.Title cover
} diff --git a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor index ce069cd5..18b0ec46 100644 --- a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor @@ -34,7 +34,7 @@ else @if (!string.IsNullOrEmpty(Item.ImageUrl)) {
- @Item.Title + @Item.Title cover
} From 3ae127fa22e4c1c65589827246fede9ebbe48775 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 24 Jul 2026 01:16:49 +0200 Subject: [PATCH 27/35] Fix Author/Artist fields reverting after being cleared on Book/Album SaveAuthorAsync/SaveArtistAsync had copied the Title field's never-allow-empty guard, so clearing the input silently skipped the API call and the old value came back on reload. Only Title (and Name-equivalent identity fields on other types) should require a non-empty value. Co-Authored-By: Claude Sonnet 5 --- src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor | 4 +--- src/BlazorApp/Components/Inventory/Pages/BookDetail.razor | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor index 81582bf8..901a887d 100644 --- a/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/AlbumDetail.razor @@ -275,9 +275,7 @@ else private async Task SaveArtistAsync(ChangeEventArgs e) { if (Album is null) return; - var artist = e.Value?.ToString(); - if (string.IsNullOrWhiteSpace(artist)) return; - Album.Artist = artist; + Album.Artist = e.Value?.ToString(); await AlbumApi.UpdateAsync(Album); } diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index 6c4ee4bc..c1fb7cb3 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -266,9 +266,7 @@ else private async Task SaveAuthorAsync(ChangeEventArgs e) { if (Book is null) return; - var author = e.Value?.ToString(); - if (string.IsNullOrWhiteSpace(author)) return; - Book.Author = author; + Book.Author = e.Value?.ToString(); await BookApi.UpdateAsync(Book); } From 63bebafbfb24c529233bc2a9335c1a531166729c Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 24 Jul 2026 01:35:32 +0200 Subject: [PATCH 28/35] Add gear category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One reusable component (SuggestInput.razor in Components/Inventory/Shared/) instead of two half-solutions: - A real text input — filters suggestions as you type (Contains, capped to 8 matches, so a long category list never dumps in full), click one to fill it, or keep typing to enter something new. No "click and it's stuck" behavior — the input stays live. - Suggestions render as a small dark-themed dropdown menu (.kt-autocomplete-menu) built from the app's own CSS variables, not a native popup, so it actually respects the dark theme and sits in normal flow instead of floating over other fields. List page: the earlier was tried and dropped: its + required width:100% fought the flex row's own sizing and forced the whole Filters group onto + a wrapped second line even with room to spare - SuggestInput is a fixed-width flex item instead. *@ + + } @if (!string.IsNullOrEmpty(gear.Brand)) { @gear.Brand } + @if (!string.IsNullOrEmpty(gear.Category)) + { + @gear.Category + } @if (gear.Year > 0) { @gear.Year diff --git a/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs index 1fd1158b..55e4bf4c 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs @@ -17,6 +17,13 @@ public partial class Gear : InventoryPageBase [SupplyParameterFromQuery(Name = "owned")] public bool OwnedFilter { get; set; } + [SupplyParameterFromQuery(Name = "category")] + public string? CategoryFilter { get; set; } + + /// Distinct categories across this tenant's gear, for the Filters row - fetched once, not + /// tied to search/page/sort state like is. + protected List Categories { get; private set; } = []; + protected override IReadOnlyDictionary? ExtraQuery { get @@ -24,7 +31,14 @@ protected override IReadOnlyDictionary? ExtraQuery var query = new Dictionary(); if (FavoriteFilter) query["IsFavorite"] = "true"; if (OwnedFilter) query["IsOwned"] = "true"; + if (!string.IsNullOrEmpty(CategoryFilter)) query["Category"] = CategoryFilter; return query.Count > 0 ? query : null; } } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + Categories = await GearApi.GetCategoriesAsync(); + } } diff --git a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor index 18b0ec46..db749ac3 100644 --- a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor @@ -48,6 +48,10 @@ else +
+ + +
@@ -75,6 +79,10 @@ else private bool _loading; private bool _loaded; + // Suggested values for the Category input's datalist - fetched once, not tied to Item/Id like the + // rest of this page's state (a category typed on this item shouldn't wait on a second round trip). + private List _categories = []; + // Public property (a framework requirement for [PersistentState]): the data loaded during the // prerender pass is carried over to the interactive circuit, so the first interactive render reuses // it instead of resetting to the spinner and re-fetching - same pattern as AlbumDetail/CarDetail. @@ -82,6 +90,11 @@ else [PersistentState] public GearDto? Item { get; set; } + protected override async Task OnInitializedAsync() + { + _categories = await GearApi.GetCategoriesAsync(); + } + protected override async Task OnParametersSetAsync() { // Item already holds this route's item when PersistentState restored the prerendered data @@ -136,6 +149,13 @@ else await SaveItemAsync(); } + private async Task SaveCategoryAsync(string? category) + { + if (Item is null) return; + Item.Category = category; + await SaveItemAsync(); + } + private async Task SaveNotesAsync(ChangeEventArgs e) { if (Item is null) return; diff --git a/src/BlazorApp/Components/Inventory/Shared/SuggestInput.razor b/src/BlazorApp/Components/Inventory/Shared/SuggestInput.razor new file mode 100644 index 00000000..b4f71884 --- /dev/null +++ b/src/BlazorApp/Components/Inventory/Shared/SuggestInput.razor @@ -0,0 +1,106 @@ +@* Free-text input with a filtered, click-to-fill suggestion dropdown - not a closed choice, the app's own + styled stand-in for an HTML (whose popup is unstyleable native chrome that clashes with the + dark theme and floats over content below it - see GearDetail.razor's Category field, the first caller). *@ +
+ + @if (_open && FilteredSuggestions.Any()) + { +
    + @foreach (var suggestion in FilteredSuggestions) + { +
  • @suggestion
  • + } +
+ } +
+ +@code { + private const int MaxSuggestions = 8; + + [Parameter] public string? Value { get; set; } + + [Parameter] public EventCallback ValueChanged { get; set; } + + [Parameter] public IReadOnlyList Suggestions { get; set; } = []; + + [Parameter] public string? Placeholder { get; set; } + + /// Applied to the wrapper div - lets a caller size this differently than the default + /// full-width detail-page field (e.g. Gear.razor's fixed-width list-filter usage). + [Parameter] public string? CssClass { get; set; } + + private string _query = ""; + private string? _lastValueParam; + private bool _open; + + // guards OnBlurAsync's delayed close against a suggestion click that's still in flight - see SelectAsync + private bool _suppressBlurClose; + + private IEnumerable FilteredSuggestions => + Suggestions.Where(s => string.IsNullOrEmpty(_query) || s.Contains(_query, StringComparison.OrdinalIgnoreCase)).Take(MaxSuggestions); + + protected override void OnParametersSet() + { + // only adopt an externally-changed Value - typing of our own must never be clobbered by the + // re-render our own CommitAsync triggers, same "sent vs received" shape as InventoryList's search box + if (Value == _lastValueParam) return; + _lastValueParam = Value; + _query = Value ?? ""; + } + + private void OnFocus() => _open = true; + + private void OnInput(ChangeEventArgs e) + { + _query = e.Value?.ToString() ?? ""; + _open = true; + } + + /// + /// A suggestion is a plain, non-focusable <li> rather than a <button>, so clicking it never + /// steals focus from the input in the first place - the input's blur handler still fires on a real + /// blur (tab away, click elsewhere), just not from this click. The delay/flag pair only has to cover + /// touch, where a tap can blur before the click event finishes. + /// + private async Task SelectAsync(string suggestion) + { + _suppressBlurClose = true; + _query = suggestion; + _open = false; + await CommitAsync(suggestion); + } + + private async Task OnBlurAsync() + { + await Task.Delay(150); + if (_suppressBlurClose) + { + _suppressBlurClose = false; + return; + } + _open = false; + await CommitAsync(_query); + } + + private async Task OnKeyDownAsync(KeyboardEventArgs e) + { + switch (e.Key) + { + case "Escape": + _open = false; + break; + case "Enter": + _open = false; + await CommitAsync(_query); + break; + } + } + + private async Task CommitAsync(string? value) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value; + if (normalized == Value) return; + await ValueChanged.InvokeAsync(normalized); + } +} diff --git a/src/BlazorApp/wwwroot/app.css b/src/BlazorApp/wwwroot/app.css index 4330246a..399b7aea 100644 --- a/src/BlazorApp/wwwroot/app.css +++ b/src/BlazorApp/wwwroot/app.css @@ -998,6 +998,60 @@ a.kt-item-row { color: inherit; text-decoration: none; } flex: 0 0 auto; } +/* Gear's category filter, sizing SuggestInput.razor's wrapper to a fixed compact width instead of the + .form-control default of 100% - a percentage width inside a flex row (like .kt-search-filters) resolves + against the row's own size, which is what pushed the whole filters row onto a wrapped second line for + no reason. Same fixed-width-flex-item shape as .kt-sort-select above, just wide enough for typed text. */ +.kt-category-select { + width: 150px; + flex: 0 0 auto; +} + +@media (max-width: 576px) { + .kt-category-select { width: 45vw; } +} + +/* ── Suggest input (SuggestInput.razor) ─────────────────────────────── + A plain text input with a filtered, click-to-fill suggestion menu - the app's own dark-themed + replacement for an HTML popup, which is unstyleable browser chrome that ignores + data-bs-theme and floats over whatever markup sits below it. */ +.kt-autocomplete { + position: relative; +} +.kt-autocomplete-menu { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + left: 0; + right: 0; + margin: 0; + padding: 0.25rem; + list-style: none; + max-height: 220px; + overflow-y: auto; + background: var(--kt-surface-2); + border: 1px solid var(--kt-border); + border-radius: var(--kt-radius); + box-shadow: var(--kt-shadow); +} +.kt-autocomplete-item { + padding: 0.4rem 0.65rem; + border-radius: calc(var(--kt-radius) - 4px); + font-size: 0.85rem; + color: var(--kt-text); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.kt-autocomplete-item:hover { + background: var(--kt-surface-3); +} +.kt-autocomplete-item.active { + background: var(--kt-accent-glow); + color: var(--kt-accent); +} + .kt-search-filters { display: flex; align-items: center; diff --git a/src/Common.System/ListSort.cs b/src/Common.System/ListSort.cs index ea4fd8ea..bf83392f 100644 --- a/src/Common.System/ListSort.cs +++ b/src/Common.System/ListSort.cs @@ -22,4 +22,7 @@ public static class ListSort /// Most recently completed video game first (max CompletedAt across a game's platform entries); items with none last. public const string LastCompleted = "completed"; + + /// Most recently bought gear first (max AcquiredAt across a gear item's owned versions); items with none last. + public const string Bought = "bought"; } diff --git a/src/Domain/Models/GearModel.cs b/src/Domain/Models/GearModel.cs index f8d9c51e..6838a53b 100644 --- a/src/Domain/Models/GearModel.cs +++ b/src/Domain/Models/GearModel.cs @@ -13,6 +13,12 @@ public class GearModel : IHasIdAndOwnerId public string? Brand { get; set; } + /// + /// Free-text category (e.g. "Electronics", "Camping") - suggested from the tenant's other gear but + /// not a closed list. Unlike a blog's tags, one gear item has exactly one category. + /// + public string? Category { get; set; } + public int? Year { get; set; } public string? Notes { get; set; } diff --git a/src/Domain/Repositories/IGearRepository.cs b/src/Domain/Repositories/IGearRepository.cs index 30190341..7c3bcfbd 100644 --- a/src/Domain/Repositories/IGearRepository.cs +++ b/src/Domain/Repositories/IGearRepository.cs @@ -1,7 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; using Keeptrack.Domain.Models; namespace Keeptrack.Domain.Repositories; public interface IGearRepository : IDataRepository { + /// + /// Distinct, non-empty values across this tenant's gear, sorted + /// alphabetically - feeds the list page's category filter buttons and the detail page's suggested + /// values. One gear item has exactly one category (unlike a blog's tags), so this is a plain + /// distinct scan, not a tag-cloud aggregation. + /// + Task> FindDistinctCategoriesAsync(string ownerId); } diff --git a/src/Infrastructure.MongoDb/Entities/Gear.cs b/src/Infrastructure.MongoDb/Entities/Gear.cs index 9cc4f838..2f3b2193 100644 --- a/src/Infrastructure.MongoDb/Entities/Gear.cs +++ b/src/Infrastructure.MongoDb/Entities/Gear.cs @@ -18,6 +18,8 @@ public class Gear : IHasIdAndOwnerId public string? Brand { get; set; } + public string? Category { get; set; } + public int? Year { get; set; } public string? Notes { get; set; } diff --git a/src/Infrastructure.MongoDb/Repositories/GearRepository.cs b/src/Infrastructure.MongoDb/Repositories/GearRepository.cs index 9e5020d7..fe59ac80 100644 --- a/src/Infrastructure.MongoDb/Repositories/GearRepository.cs +++ b/src/Infrastructure.MongoDb/Repositories/GearRepository.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using System.Linq.Expressions; +using System.Threading.Tasks; +using Keeptrack.Common.System; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.Infrastructure.MongoDb.Entities; @@ -16,6 +19,18 @@ public class GearRepository(IMongoDatabase mongoDatabase, ILogger> SortTitleField => x => x.Title; + /// + /// "Bought" needs the max AcquiredAt across a gear item's + /// array (the most recently acquired copy), not a single scalar field, so it can't use the shared + /// SortSecondaryDateField hook - same shape as VideoGameRepository.GetSort's "last + /// completed" override, relying on MongoDB's native max-on-descending-sort semantics for a dotted + /// array field rather than an aggregation pipeline. + /// + protected override SortDefinition GetSort(string? sort) => + sort == ListSort.Bought + ? Builders.Sort.Descending("owned_versions.acquired_at").Descending("_id") + : base.GetSort(sort); + protected override FilterDefinition GetFilter(string ownerId, string? search, GearModel input) { var builder = Builders.Filter; @@ -23,8 +38,21 @@ protected override FilterDefinition GetFilter(string ownerId, string? sear if (!string.IsNullOrEmpty(search)) filter &= builder.Where(f => f.Title.Contains(search, StringComparison.CurrentCultureIgnoreCase) || (f.Brand != null && f.Brand.Contains(search, StringComparison.CurrentCultureIgnoreCase))); if (input.IsFavorite) filter &= builder.Eq(f => f.IsFavorite, true); + if (!string.IsNullOrEmpty(input.Category)) filter &= builder.Eq(f => f.Category, input.Category); // "owned" means at least one owned version - see MovieRepository.GetFilter if (input.IsOwned) filter &= builder.SizeGt(f => f.OwnedVersions, 0); return filter; } + + public async Task> FindDistinctCategoriesAsync(string ownerId) + { + var builder = Builders.Filter; + // "has a category" is the negation of TvShowRepository/MovieRepository's UnresolvedFilter shape + // (matches null OR empty string) - both generations of "unset" must be excluded here too. + var filter = builder.Eq(f => f.OwnerId, ownerId) & builder.Ne(f => f.Category, null) & builder.Ne(f => f.Category, string.Empty); + var cursor = await GetCollection().DistinctAsync(f => f.Category, filter); + var categories = await cursor.ToListAsync(); + categories.Sort(StringComparer.OrdinalIgnoreCase); + return categories!; + } } diff --git a/src/WebApi.Contracts/Dto/GearDto.cs b/src/WebApi.Contracts/Dto/GearDto.cs index 688c81a7..56493a39 100644 --- a/src/WebApi.Contracts/Dto/GearDto.cs +++ b/src/WebApi.Contracts/Dto/GearDto.cs @@ -25,6 +25,13 @@ public class GearDto : IHasId /// Sony public string? Brand { get; set; } + /// + /// Free-text category (e.g. "Electronics", "Camping") - suggested from the tenant's other gear but + /// not a closed list. Unlike a blog's tags, one gear item has exactly one category. + /// + /// Electronics + public string? Category { get; set; } + /// /// Year. /// diff --git a/src/WebApi/Controllers/GearController.cs b/src/WebApi/Controllers/GearController.cs index 27287e6a..5a7b0d6f 100644 --- a/src/WebApi/Controllers/GearController.cs +++ b/src/WebApi/Controllers/GearController.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Threading.Tasks; using Keeptrack.Domain.Models; using Keeptrack.Domain.Repositories; using Keeptrack.WebApi.Mappers; @@ -10,4 +12,18 @@ namespace Keeptrack.WebApi.Controllers; [Authorize(Policy = "MemberOnly")] [Route("api/gear")] public class GearController(IDtoMapper mapper, IGearRepository dataRepository) - : DataCrudControllerBase(mapper, dataRepository); + : DataCrudControllerBase(mapper, dataRepository) +{ + /// + /// Distinct categories already used across this tenant's gear - feeds the list page's category + /// filter buttons and the detail page's suggested values. See + /// . + /// + [HttpGet("categories")] + [ProducesResponseType(200)] + public async Task>> GetCategories() + { + var categories = await dataRepository.FindDistinctCategoriesAsync(this.GetUserId()); + return Ok(categories); + } +} diff --git a/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs b/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs index 919bc2b9..a8c8b41d 100644 --- a/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs +++ b/test/WebApi.IntegrationTests/Resources/GearResourceTest.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; @@ -77,6 +79,94 @@ public async Task GearResourceSearch_FiltersByTitle_IsOk() } } + [Fact] + public async Task GearResourceCategoryFilter_OnlyReturnsMatchingItems_IsOk() + { + await Authenticate(); + + var title = $"CategoryTarget-{System.Guid.NewGuid():N}"; + var electronics = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Electronics" }); + var camping = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Camping" }); + var uncategorized = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?Category=Electronics&search={title}"); + results.Items.Should().ContainSingle(x => x.Id == electronics.Id); + results.Items.Should().NotContain(x => x.Id == camping.Id || x.Id == uncategorized.Id); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{electronics.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{camping.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{uncategorized.Id}"); + } + } + + [Fact] + public async Task GearCategoriesEndpoint_ReturnsDistinctSortedCategories_IsOk() + { + await Authenticate(); + + var title = $"CategoryList-{System.Guid.NewGuid():N}"; + var first = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Zetatools" }); + var second = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Anvils" }); + // same category twice must appear only once, and an unset category must never appear at all + var duplicate = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title, Category = "Zetatools" }); + var uncategorized = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + + try + { + var categories = await GetAsync>($"/{ResourceEndpoint}/categories"); + categories.Should().Contain(["Anvils", "Zetatools"]); + categories.Should().OnlyHaveUniqueItems(); + var anvilsIndex = categories.IndexOf("Anvils"); + var zetatoolsIndex = categories.IndexOf("Zetatools"); + anvilsIndex.Should().BeLessThan(zetatoolsIndex, "results are sorted alphabetically"); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{first.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{second.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{duplicate.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{uncategorized.Id}"); + } + } + + [Fact] + public async Task GearResourceBoughtSort_OrdersByMostRecentlyAcquiredCopyDescending_IsOk() + { + await Authenticate(); + + var title = $"BoughtSort-{System.Guid.NewGuid():N}"; + var olderPurchase = await PostAsync($"/{ResourceEndpoint}", new GearDto + { + Title = title, + OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, AcquiredAt = new DateOnly(2020, 1, 1) }] + }); + var recentPurchase = await PostAsync($"/{ResourceEndpoint}", new GearDto + { + Title = title, + OwnedVersions = [new OwnedVersionDto { CopyType = CopyType.Physical, AcquiredAt = new DateOnly(2024, 6, 1) }] + }); + var neverOwned = await PostAsync($"/{ResourceEndpoint}", new GearDto { Title = title }); + + try + { + var results = await GetAsync>($"/{ResourceEndpoint}?sort=bought&search={title}"); + var ids = results.Items.Select(x => x.Id).ToList(); + ids.IndexOf(recentPurchase.Id).Should().BeLessThan(ids.IndexOf(olderPurchase.Id), + "the most recently acquired copy sorts first"); + ids.Should().Contain(neverOwned.Id, "an unset acquisition date still falls back to the newest-first tie-break, it isn't excluded"); + } + finally + { + await DeleteAsync($"/{ResourceEndpoint}/{olderPurchase.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{recentPurchase.Id}"); + await DeleteAsync($"/{ResourceEndpoint}/{neverOwned.Id}"); + } + } + [Fact] public async Task GearResourceOwnedAndFavoriteFilters_OnlyReturnMatchingItems_IsOk() { From 6c69f68d35e66b3d06742befd62dedcca5ab2f5b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 24 Jul 2026 18:20:00 +0200 Subject: [PATCH 29/35] Improve gear category UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gear list page (Gear.razor): the category filter is now a classic was tried and dropped: its - required width:100% fought the flex row's own sizing and forced the whole Filters group onto - a wrapped second line even with room to spare - SuggestInput is a fixed-width flex item instead. *@ - + @* A classic, closed-choice dropdown rather than SuggestInput's free-text field: unlike the + detail page (where typing a brand-new category is the point), this is a filter over + categories that already exist, so picking from the fixed list is the right affordance. + The "All categories" option doubles as the clear action - no separate icon/button needed. *@ + } diff --git a/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs b/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs index 55e4bf4c..23ed3dc7 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs +++ b/src/BlazorApp/Components/Inventory/Pages/Gear.razor.cs @@ -41,4 +41,7 @@ protected override async Task OnInitializedAsync() await base.OnInitializedAsync(); Categories = await GearApi.GetCategoriesAsync(); } + + protected void OnCategoryFilterChanged(ChangeEventArgs e) => + SetFilter("category", string.IsNullOrEmpty(e.Value?.ToString()) ? null : e.Value.ToString()); } diff --git a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor index db749ac3..8ff9f9be 100644 --- a/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/GearDetail.razor @@ -50,7 +50,17 @@ else
- +
+
+ +
+ @if (!string.IsNullOrEmpty(Item.Category)) + { + + } +
From 2a541fa1641d79e5b2152c70469ad2527b46a3b9 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sun, 26 Jul 2026 16:15:43 +0200 Subject: [PATCH 30/35] Code improvement --- .../Account/UserPreferencesApiClient.cs | 10 ++-- .../Account/UserPreferencesState.cs | 15 +++--- .../Inventory/Clients/AlbumApiClient.cs | 20 +------ .../Inventory/Clients/BookApiClient.cs | 20 +------ .../Inventory/Clients/GearApiClient.cs | 10 ++-- .../Clients/InventoryApiClientBase.cs | 54 +++++++++++++++---- .../Inventory/Clients/MovieApiClient.cs | 25 +-------- .../Inventory/Clients/SongApiClient.cs | 18 ++++--- .../Inventory/Clients/TvShowApiClient.cs | 25 +-------- .../Inventory/Clients/VideoGameApiClient.cs | 20 +------ .../Inventory/Pages/BookDetail.razor | 12 ++--- .../Components/Inventory/Pages/Gear.razor | 5 +- ...frastructureServiceCollectionExtensions.cs | 6 ++- src/BlazorApp/GlobalUsings.cs | 2 - src/BlazorApp/Program.cs | 6 +-- 15 files changed, 96 insertions(+), 152 deletions(-) diff --git a/src/BlazorApp/Components/Account/UserPreferencesApiClient.cs b/src/BlazorApp/Components/Account/UserPreferencesApiClient.cs index 73fd5964..3a984ffc 100644 --- a/src/BlazorApp/Components/Account/UserPreferencesApiClient.cs +++ b/src/BlazorApp/Components/Account/UserPreferencesApiClient.cs @@ -4,12 +4,16 @@ namespace Keeptrack.BlazorApp.Components.Account; public sealed class UserPreferencesApiClient(HttpClient http) { + private const string ApiUserPreferences = "/api/user-preferences"; + public async Task GetAsync() { - var result = await http.GetFromJsonAsync("/api/user-preferences"); + var result = await http.GetFromJsonAsync(ApiUserPreferences); return result ?? new UserPreferencesDto(); } - public async Task UpdateAsync(UserPreferencesDto dto) => - (await http.PutAsJsonAsync("/api/user-preferences", dto)).EnsureSuccessStatusCode(); + public async Task UpdateAsync(UserPreferencesDto dto) + { + (await http.PutAsJsonAsync(ApiUserPreferences, dto)).EnsureSuccessStatusCode(); + } } diff --git a/src/BlazorApp/Components/Account/UserPreferencesState.cs b/src/BlazorApp/Components/Account/UserPreferencesState.cs index 61db8cba..77b4dfde 100644 --- a/src/BlazorApp/Components/Account/UserPreferencesState.cs +++ b/src/BlazorApp/Components/Account/UserPreferencesState.cs @@ -4,18 +4,19 @@ namespace Keeptrack.BlazorApp.Components.Account; /// /// Circuit-scoped cache of the caller's own (registered AddScoped). -/// Preference checks now happen in more than one place per page (e.g. every owned copy's -/// OwnedVersionFields instance on a detail page), so each consumer fetching independently would fire -/// one HTTP request per instance; this fetches once per circuit and every consumer shares the same -/// in-flight/completed instead. (only called from -/// Manage.razor) refreshes the cache immediately, so a page navigated to afterward sees the new value -/// without a second round trip. +/// Preference checks now happen in more than one place per page (e.g. every owned copy's OwnedVersionFields instance on a detail page), +/// so each consumer fetching independently would fire one HTTP request per instance; +/// this fetches once per circuit and every consumer shares the same in-flight/completed instead. +/// (only called from Manage.razor) refreshes the cache immediately, so a page navigated to afterward sees the new value without a second round trip. /// public sealed class UserPreferencesState(UserPreferencesApiClient api) { private Task? _cached; - public Task GetAsync() => _cached ??= api.GetAsync(); + public Task GetAsync() + { + return _cached ??= api.GetAsync(); + } public async Task UpdateAsync(UserPreferencesDto dto) { diff --git a/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs index 977bb18b..0706bce1 100644 --- a/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/AlbumApiClient.cs @@ -3,25 +3,7 @@ namespace Keeptrack.BlazorApp.Components.Inventory.Clients; public sealed class AlbumApiClient(HttpClient http) - : InventoryApiClientBase(http) + : InventoryApiClientBase(http, hasReference: true) { protected override string ApiResourceName => "/api/albums"; - - public async Task RefreshReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } - - /// - /// Admin-only: unlinks and permanently deletes the shared reference document - /// (POST api/albums/{id}/unlink-reference on WebApi). - /// - public async Task UnlinkReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } } diff --git a/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs index 9feb451c..f12668c5 100644 --- a/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/BookApiClient.cs @@ -3,25 +3,7 @@ namespace Keeptrack.BlazorApp.Components.Inventory.Clients; public sealed class BookApiClient(HttpClient http) - : InventoryApiClientBase(http) + : InventoryApiClientBase(http, hasReference: true) { protected override string ApiResourceName => "/api/books"; - - public async Task RefreshReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } - - /// - /// Admin-only: unlinks and permanently deletes the shared reference document - /// (POST api/books/{id}/unlink-reference on WebApi). - /// - public async Task UnlinkReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } } diff --git a/src/BlazorApp/Components/Inventory/Clients/GearApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/GearApiClient.cs index 0abdeeba..f3781570 100644 --- a/src/BlazorApp/Components/Inventory/Clients/GearApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/GearApiClient.cs @@ -7,7 +7,11 @@ public sealed class GearApiClient(HttpClient http) { protected override string ApiResourceName => "/api/gear"; - /// Distinct categories already used across this tenant's gear - see GearController.GetCategories. - public async Task> GetCategoriesAsync() => - await Http.GetFromJsonAsync>($"{ApiResourceName}/categories") ?? []; + /// + /// Distinct categories already used across this tenant's gear - see GearController.GetCategories. + /// + public async Task> GetCategoriesAsync() + { + return await Http.GetFromJsonAsync>($"{ApiResourceName}/categories") ?? []; + } } diff --git a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs index 78b869eb..28dee483 100644 --- a/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs +++ b/src/BlazorApp/Components/Inventory/Clients/InventoryApiClientBase.cs @@ -2,9 +2,11 @@ namespace Keeptrack.BlazorApp.Components.Inventory.Clients; -public abstract class InventoryApiClientBase(HttpClient http) +public abstract class InventoryApiClientBase(HttpClient http, bool hasReference = false) where TDto : IHasId { + private sealed record ApiError(string? Error); + protected abstract string ApiResourceName { get; } /// @@ -40,20 +42,19 @@ public async Task> GetAsync(string search, int page, int pageS public async Task AddAsync(TDto movie) { var response = await http.PostAsJsonAsync($"{ApiResourceName}", movie); - if (!response.IsSuccessStatusCode) + if (response.IsSuccessStatusCode) { - // the API returns error bodies (free-tier quota 403s, ApiExceptionFilterAttribute's 400s/500s) - - // surfacing that text beats EnsureSuccessStatusCode's opaque "403 (Forbidden)" in the Add form - var body = await response.Content.ReadFromJsonAsync(); - throw new InvalidOperationException(string.IsNullOrEmpty(body?.Error) - ? $"The request failed ({(int)response.StatusCode})." - : body.Error); + return (await response.Content.ReadFromJsonAsync())!; } - return (await response.Content.ReadFromJsonAsync())!; - } + // the API returns error bodies (free-tier quota 403s, ApiExceptionFilterAttribute's 400s/500s) - + // surfacing that text beats EnsureSuccessStatusCode's opaque "403 (Forbidden)" in the Add form + var body = await response.Content.ReadFromJsonAsync(); + throw new InvalidOperationException(string.IsNullOrEmpty(body?.Error) + ? $"The request failed ({(int)response.StatusCode})." + : body.Error); - private sealed record ApiError(string? Error); + } public async Task UpdateAsync(TDto movie) { @@ -64,4 +65,35 @@ public async Task DeleteAsync(string id) { (await http.DeleteAsync($"{ApiResourceName}/{id}")).EnsureSuccessStatusCode(); } + + /// + /// User-triggered, exact-match-only re-check against the local reference collection (POST api/{type}/{id}/refresh-reference on WebApi). + /// Returns the (possibly now-linked) item so the caller can tell whether a match was actually found. + /// + public async Task RefreshReferenceAsync(string id) + { + if (!hasReference) + { + throw new NotImplementedException(); + } + + var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } + + /// + /// Admin-only: unlinks and permanently deletes the shared reference document (POST api/{type}/{id}/unlink-reference on WebApi). + /// + public async Task UnlinkReferenceAsync(string id) + { + if (!hasReference) + { + throw new NotImplementedException(); + } + + var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); + response.EnsureSuccessStatusCode(); + return (await response.Content.ReadFromJsonAsync())!; + } } diff --git a/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs index 1cfa0983..3c8c4946 100644 --- a/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/MovieApiClient.cs @@ -3,30 +3,7 @@ namespace Keeptrack.BlazorApp.Components.Inventory.Clients; public sealed class MovieApiClient(HttpClient http) - : InventoryApiClientBase(http) + : InventoryApiClientBase(http, hasReference: true) { protected override string ApiResourceName => "/api/movies"; - - /// - /// User-triggered, exact-match-only re-check against the local reference collection - /// (POST api/movies/{id}/refresh-reference on WebApi). Returns the (possibly now-linked) item so the - /// caller can tell whether a match was actually found. - /// - public async Task RefreshReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } - - /// - /// Admin-only: unlinks and permanently deletes the shared reference document - /// (POST api/movies/{id}/unlink-reference on WebApi). - /// - public async Task UnlinkReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } } diff --git a/src/BlazorApp/Components/Inventory/Clients/SongApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/SongApiClient.cs index 39e7597f..30652fac 100644 --- a/src/BlazorApp/Components/Inventory/Clients/SongApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/SongApiClient.cs @@ -8,17 +8,23 @@ public sealed class SongApiClient(HttpClient http) protected override string ApiResourceName => "/api/songs"; /// - /// The single shared "add a real track" operation - reused by both AlbumDetail.razor's - /// per-track "+ Add to playlist" and PlaylistDetail.razor's "Add from album" picker, so picking - /// the same track from two different places (or twice from the same place) reuses one - /// instead of creating a duplicate. Relies on SongRepository.GetFilter's exact-match on - /// / - no dedicated WebApi endpoint needed. + /// The single shared "add a real track" operation - + /// reused by both AlbumDetail.razor's per-track "+ Add to playlist" and PlaylistDetail.razor's "Add from album" picker, + /// so picking the same track from two different places (or twice from the same place) reuses one instead of creating a duplicate. + /// Relies on SongRepository.GetFilter's exact-match on / - no dedicated WebApi endpoint needed. /// public async Task GetOrCreateForTrackAsync(string albumId, string position, string title, string? duration, string? artist) { var existing = await GetAsync("", 1, 1, new Dictionary { ["AlbumId"] = albumId, ["TrackPosition"] = position }); if (existing.Items.Count > 0) return existing.Items[0]; - return await AddAsync(new SongDto { Title = title, Artist = artist, AlbumId = albumId, TrackPosition = position, Duration = duration }); + return await AddAsync(new SongDto + { + Title = title, + Artist = artist, + AlbumId = albumId, + TrackPosition = position, + Duration = duration + }); } } diff --git a/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs index 8bd2ad4e..7c0933bc 100644 --- a/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/TvShowApiClient.cs @@ -3,30 +3,7 @@ namespace Keeptrack.BlazorApp.Components.Inventory.Clients; public class TvShowApiClient(HttpClient http) - : InventoryApiClientBase(http) + : InventoryApiClientBase(http, hasReference: true) { protected override string ApiResourceName => "/api/tv-shows"; - - /// - /// User-triggered, exact-match-only re-check against the local reference collection - /// (POST api/tv-shows/{id}/refresh-reference on WebApi). Returns the (possibly now-linked) item so the - /// caller can tell whether a match was actually found. - /// - public async Task RefreshReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } - - /// - /// Admin-only: unlinks and permanently deletes the shared reference document - /// (POST api/tv-shows/{id}/unlink-reference on WebApi). - /// - public async Task UnlinkReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } } diff --git a/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs b/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs index 45df7e83..9d551931 100644 --- a/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs +++ b/src/BlazorApp/Components/Inventory/Clients/VideoGameApiClient.cs @@ -3,25 +3,7 @@ namespace Keeptrack.BlazorApp.Components.Inventory.Clients; public class VideoGameApiClient(HttpClient http) - : InventoryApiClientBase(http) + : InventoryApiClientBase(http, hasReference: true) { protected override string ApiResourceName => "/api/video-games"; - - public async Task RefreshReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/refresh-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } - - /// - /// Admin-only: unlinks and permanently deletes the shared reference document - /// (POST api/video-games/{id}/unlink-reference on WebApi). - /// - public async Task UnlinkReferenceAsync(string id) - { - var response = await Http.PostAsync($"{ApiResourceName}/{id}/unlink-reference", null); - response.EnsureSuccessStatusCode(); - return (await response.Content.ReadFromJsonAsync())!; - } } diff --git a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor index c1fb7cb3..5cb77545 100644 --- a/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor +++ b/src/BlazorApp/Components/Inventory/Pages/BookDetail.razor @@ -162,16 +162,14 @@ else private static readonly TimeSpan RefreshMessageDuration = TimeSpan.FromSeconds(4); // _loading is delay-gated (see LoadingIndicator) and only turns on for a load that's genuinely slow. - // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted - // prerender state - both default false so the forced render Blazor triggers right after the - // synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) shows blank instead - // of a spinner flash on every navigation to this page. + // _loaded tracks whether a load attempt has finished at all, fresh or restored from persisted prerender state - + // both default false so the forced render Blazor triggers right after the synchronous prefix of OnParametersSetAsync (before any awaited fetch resolves) + // shows blank instead of a spinner flash on every navigation to this page. private bool _loading; private bool _loaded; - // Public properties (a framework requirement for [PersistentState]): the data loaded during the - // prerender pass is carried over to the interactive circuit, so the first interactive render reuses - // it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. + // Public properties (a framework requirement for [PersistentState]): the data loaded during the prerender pass is carried over to the interactive circuit, + // so the first interactive render reuses it instead of resetting to the spinner and re-fetching - same pattern as MovieDetail. [PersistentState] public BookDto? Book { get; set; } diff --git a/src/BlazorApp/Components/Inventory/Pages/Gear.razor b/src/BlazorApp/Components/Inventory/Pages/Gear.razor index 88ea4ee5..e973423b 100644 --- a/src/BlazorApp/Components/Inventory/Pages/Gear.razor +++ b/src/BlazorApp/Components/Inventory/Pages/Gear.razor @@ -36,9 +36,8 @@ @if (Categories.Count > 0) { - @* A classic, closed-choice dropdown rather than SuggestInput's free-text field: unlike the - detail page (where typing a brand-new category is the point), this is a filter over - categories that already exist, so picking from the fixed list is the right affordance. + @* A classic, closed-choice dropdown rather than SuggestInput's free-text field: unlike the detail page (where typing a brand-new category is the point), + this is a filter over ategories that already exist, so picking from the fixed list is the right affordance. The "All categories" option doubles as the clear action - no separate icon/button needed. *@