Add platform powder catalog, catalog-first lookup, and label scanner
- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with full spec fields: cure temp/time, finish, color families, clear coat flag, coverage sq ft/lb, transfer efficiency, IsUserContributed - Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields - PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape, Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view with Tenant Contributed card - Unified smart Lookup button on inventory Create/Edit: catalog hit fills all fields via catalogSnapshot pattern; AI augments cure/finish data from product URL if subscription enabled; catalog miss falls through to AI lookup - In-browser label scanner (_LabelScanModal): getUserMedia live camera feed, jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends captured frame to Claude vision via /Inventory/ScanLabel - ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision path (ScanLabelAsync); auto-inserts unrecognized products as IsUserContributed=true; returns wasInCatalog/addedToCatalog flags Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -682,6 +682,200 @@ public class InventoryController : Controller
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Augments a catalog fill with cure specs, color families, and finish by fetching the
|
||||
/// product's known URL and running it through Claude. Skips Serper — the URL is already
|
||||
/// known from the catalog record so no search step is needed. Gated behind the same
|
||||
/// AI Inventory Assist subscription flag as AiLookup.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> AiAugmentFromUrl(
|
||||
[FromForm] string? productUrl,
|
||||
[FromForm] string? colorName)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
|
||||
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled." });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(productUrl))
|
||||
return Json(new { success = false, errorMessage = "No product URL provided." });
|
||||
|
||||
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a base64 label photo or a decoded QR URL from the in-browser label scanner,
|
||||
/// runs it through Claude (vision for photos, URL-fetch for QR), searches the platform
|
||||
/// catalog, and — when the product is not yet in the catalog and enough data was extracted
|
||||
/// — inserts it automatically as a user-contributed entry so future scans resolve instantly.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> ScanLabel(
|
||||
[FromForm] string? imageBase64,
|
||||
[FromForm] string? mediaType,
|
||||
[FromForm] string? qrUrl)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
if (!await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId))
|
||||
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account." });
|
||||
|
||||
InventoryAiLookupResult aiResult;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(qrUrl))
|
||||
{
|
||||
// QR path: fetch the product page and let Claude extract specs from its content
|
||||
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
|
||||
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
||||
aiResult.SpecPageUrl = qrUrl;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(imageBase64))
|
||||
{
|
||||
// Vision path: Claude reads the label photo directly
|
||||
aiResult = await _aiLookupService.ScanLabelAsync(imageBase64, mediaType ?? "image/jpeg");
|
||||
}
|
||||
else
|
||||
{
|
||||
return Json(new { success = false, errorMessage = "Provide either a label image or a QR code URL." });
|
||||
}
|
||||
|
||||
if (!aiResult.Success)
|
||||
return Json(new { success = false, errorMessage = aiResult.ErrorMessage });
|
||||
|
||||
// Search catalog by SKU first (most precise), then fall back to color name
|
||||
var sku = aiResult.ManufacturerPartNumber?.Trim();
|
||||
var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim();
|
||||
var colorName = aiResult.ColorName?.Trim();
|
||||
|
||||
PowderCatalogItem? catalogMatch = null;
|
||||
if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer))
|
||||
{
|
||||
var skuLower = sku.ToLower();
|
||||
var mfrLower = manufacturer.ToLower();
|
||||
var skuMatches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
|
||||
catalogMatch = skuMatches.FirstOrDefault();
|
||||
}
|
||||
|
||||
var wasInCatalog = catalogMatch != null;
|
||||
var addedToCatalog = false;
|
||||
|
||||
// Auto-contribute: insert into platform catalog if we have the minimum viable fields
|
||||
// and this SKU isn't already there
|
||||
if (!wasInCatalog
|
||||
&& !string.IsNullOrEmpty(sku)
|
||||
&& !string.IsNullOrEmpty(manufacturer)
|
||||
&& !string.IsNullOrEmpty(colorName))
|
||||
{
|
||||
try
|
||||
{
|
||||
var newItem = new PowderCatalogItem
|
||||
{
|
||||
VendorName = manufacturer,
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
CureTemperatureF = aiResult.CureTemperatureF,
|
||||
CureTimeMinutes = aiResult.CureTimeMinutes,
|
||||
Finish = aiResult.Finish,
|
||||
ColorFamilies = aiResult.ColorFamilies,
|
||||
RequiresClearCoat = aiResult.RequiresClearCoat,
|
||||
CoverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
|
||||
TransferEfficiency= aiResult.TransferEfficiency,
|
||||
ImageUrl = aiResult.ImageUrl,
|
||||
ProductUrl = aiResult.SpecPageUrl,
|
||||
IsUserContributed = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _unitOfWork.PowderCatalog.AddAsync(newItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
addedToCatalog = true;
|
||||
_logger.LogInformation("Label scan contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Unique constraint violation means another request beat us — not an error
|
||||
_logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
return Json(new
|
||||
{
|
||||
success = true,
|
||||
manufacturer = manufacturer,
|
||||
manufacturerPartNumber= sku,
|
||||
colorName = colorName,
|
||||
description = aiResult.Description,
|
||||
finish = catalogMatch?.Finish ?? aiResult.Finish,
|
||||
cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF,
|
||||
cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes,
|
||||
colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies,
|
||||
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
|
||||
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
|
||||
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
|
||||
unitPrice = catalogMatch?.UnitPrice ?? 0m,
|
||||
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
|
||||
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
|
||||
sdsUrl = catalogMatch?.SdsUrl,
|
||||
tdsUrl = catalogMatch?.TdsUrl,
|
||||
vendorName = manufacturer,
|
||||
wasInCatalog = wasInCatalog,
|
||||
addedToCatalog = addedToCatalog,
|
||||
reasoning = aiResult.Reasoning,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches the platform-level PowderCatalogItems table by SKU or color name and returns
|
||||
/// up to 10 matches as JSON. Called by the inventory Create/Edit form before falling back
|
||||
/// to the AI Lookup, avoiding unnecessary API calls for known products.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> CatalogLookup(string? q, string? vendor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
||||
return Json(Array.Empty<object>());
|
||||
|
||||
var term = q.Trim().ToLower();
|
||||
var vendorTerm = vendor?.Trim().ToLower();
|
||||
|
||||
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == term ||
|
||||
p.ColorName.ToLower().Contains(term) ||
|
||||
p.Sku.ToLower().Contains(term));
|
||||
|
||||
// When a vendor hint is provided, prefer records where VendorName matches,
|
||||
// then fall back to all results so the user still sees cross-vendor options.
|
||||
var results = matches
|
||||
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
|
||||
.ThenBy(p => !string.IsNullOrEmpty(vendorTerm) && p.VendorName.ToLower().Contains(vendorTerm) ? 0 : 1)
|
||||
.ThenBy(p => p.ColorName)
|
||||
.Take(10)
|
||||
.Select(p => new
|
||||
{
|
||||
id = p.Id,
|
||||
vendorName = p.VendorName,
|
||||
sku = p.Sku,
|
||||
colorName = p.ColorName,
|
||||
description = p.Description,
|
||||
unitPrice = p.UnitPrice,
|
||||
imageUrl = p.ImageUrl,
|
||||
sdsUrl = p.SdsUrl,
|
||||
tdsUrl = p.TdsUrl,
|
||||
applicationGuideUrl = p.ApplicationGuideUrl,
|
||||
productUrl = p.ProductUrl,
|
||||
isDiscontinued = p.IsDiscontinued,
|
||||
cureTemperatureF = p.CureTemperatureF,
|
||||
cureTimeMinutes = p.CureTimeMinutes,
|
||||
finish = p.Finish,
|
||||
colorFamilies = p.ColorFamilies,
|
||||
requiresClearCoat = p.RequiresClearCoat,
|
||||
coverageSqFtPerLb = p.CoverageSqFtPerLb,
|
||||
transferEfficiency = p.TransferEfficiency
|
||||
});
|
||||
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
|
||||
/// inventory item names on create and edit so the list view is consistently formatted
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Application.DTOs.Inventory;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class PowderCatalogController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<PowderCatalogController> _logger;
|
||||
|
||||
public PowderCatalogController(IUnitOfWork unitOfWork, ILogger<PowderCatalogController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows platform-level catalog stats and the import form.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var all = await _unitOfWork.PowderCatalog.GetAllAsync();
|
||||
var list = all.ToList();
|
||||
|
||||
var stats = new PowderCatalogStatsDto
|
||||
{
|
||||
TotalProducts = list.Count,
|
||||
ActiveProducts = list.Count(p => !p.IsDiscontinued),
|
||||
DiscontinuedProducts = list.Count(p => p.IsDiscontinued),
|
||||
VendorCount = list.Select(p => p.VendorName).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
UserContributedProducts = list.Count(p => p.IsUserContributed),
|
||||
LastImportedAt = list.Any() ? list.Max(p => p.LastSyncedAt) : null
|
||||
};
|
||||
|
||||
return View(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts a JSON file upload (Prismatic Powders scrape format) and upserts all records
|
||||
/// into PowderCatalogItems. Strips page-scrape boilerplate from descriptions.
|
||||
/// Existing records matched by (VendorName, Sku) are updated in-place; new ones are inserted.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(50 * 1024 * 1024)] // 50 MB
|
||||
public async Task<IActionResult> Import(IFormFile file, string vendorName = "Prismatic Powders")
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
TempData["Error"] = "Please select a JSON file to import.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
PowderCatalogImportResult result;
|
||||
try
|
||||
{
|
||||
result = await ImportJsonAsync(file, vendorName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Powder catalog import failed");
|
||||
TempData["Error"] = $"Import failed: {ex.Message}";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
TempData["Error"] = result.ErrorMessage ?? "Import failed.";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["Success"] = $"Import complete — {result.Inserted:N0} inserted, {result.Updated:N0} updated, {result.Skipped:N0} skipped.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint used by the inventory form to search the catalog by SKU or color name.
|
||||
/// SKU exact matches are ranked first; color name substring matches follow.
|
||||
/// Returns up to 10 results. Accessible to all authenticated users so the inventory
|
||||
/// Create/Edit form can call it without a separate policy.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Lookup(string? q)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
||||
return Json(Array.Empty<PowderCatalogLookupResult>());
|
||||
|
||||
var term = q.Trim();
|
||||
|
||||
// Exact SKU match first, then color name contains
|
||||
var all = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||
p.Sku.ToLower() == term.ToLower() ||
|
||||
p.ColorName.ToLower().Contains(term.ToLower()) ||
|
||||
p.Sku.ToLower().Contains(term.ToLower()));
|
||||
|
||||
var results = all
|
||||
.OrderBy(p => p.Sku.ToLower() == term.ToLower() ? 0 : 1)
|
||||
.ThenBy(p => p.ColorName)
|
||||
.Take(10)
|
||||
.Select(p => new PowderCatalogLookupResult
|
||||
{
|
||||
Id = p.Id,
|
||||
VendorName = p.VendorName,
|
||||
Sku = p.Sku,
|
||||
ColorName = p.ColorName,
|
||||
Description = p.Description,
|
||||
UnitPrice = p.UnitPrice,
|
||||
ImageUrl = p.ImageUrl,
|
||||
SdsUrl = p.SdsUrl,
|
||||
TdsUrl = p.TdsUrl,
|
||||
ApplicationGuideUrl = p.ApplicationGuideUrl,
|
||||
ProductUrl = p.ProductUrl,
|
||||
IsDiscontinued = p.IsDiscontinued
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Json(results);
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<PowderCatalogImportResult> ImportJsonAsync(IFormFile file, string vendorName)
|
||||
{
|
||||
using var stream = file.OpenReadStream();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("results", out var resultsEl) ||
|
||||
resultsEl.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
|
||||
}
|
||||
|
||||
// Load existing records for this vendor into a lookup dictionary
|
||||
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName))
|
||||
.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
int inserted = 0, updated = 0, skipped = 0, errors = 0;
|
||||
var toAdd = new List<PowderCatalogItem>();
|
||||
|
||||
foreach (var item in resultsEl.EnumerateArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
var sku = item.GetStringOrNull("sku");
|
||||
var colorName = item.GetStringOrNull("color_name");
|
||||
if (string.IsNullOrWhiteSpace(sku) || string.IsNullOrWhiteSpace(colorName))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var rawDesc = item.GetStringOrNull("description");
|
||||
var cleanDesc = StripBoilerplate(rawDesc);
|
||||
var unitPrice = ExtractBasePrice(item);
|
||||
var priceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl)
|
||||
? tiersEl.GetRawText()
|
||||
: null;
|
||||
|
||||
if (existing.TryGetValue(sku, out var record))
|
||||
{
|
||||
record.ColorName = colorName;
|
||||
record.Description = cleanDesc;
|
||||
record.UnitPrice = unitPrice;
|
||||
record.PriceTiersJson = priceTiersJson;
|
||||
record.ImageUrl = item.GetStringOrNull("sample_image_url");
|
||||
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url");
|
||||
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url");
|
||||
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url");
|
||||
record.ProductUrl = item.GetStringOrNull("product_url");
|
||||
record.UpdatedAt = now;
|
||||
record.LastSyncedAt = now;
|
||||
await _unitOfWork.PowderCatalog.UpdateAsync(record);
|
||||
updated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
toAdd.Add(new PowderCatalogItem
|
||||
{
|
||||
VendorName = vendorName,
|
||||
Sku = sku,
|
||||
ColorName = colorName,
|
||||
Description = cleanDesc,
|
||||
UnitPrice = unitPrice,
|
||||
PriceTiersJson = priceTiersJson,
|
||||
ImageUrl = item.GetStringOrNull("sample_image_url"),
|
||||
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
|
||||
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
|
||||
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
|
||||
ProductUrl = item.GetStringOrNull("product_url"),
|
||||
CreatedAt = now,
|
||||
LastSyncedAt = now
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Skipping catalog record due to parse error");
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (toAdd.Any())
|
||||
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return new PowderCatalogImportResult
|
||||
{
|
||||
Success = true,
|
||||
Inserted = inserted,
|
||||
Updated = updated,
|
||||
Skipped = skipped,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips page-scrape boilerplate that starts at "PRODUCT SUPPORT" or "CUSTOMER SERVICE".
|
||||
/// Returns a trimmed first-paragraph description suitable for display.
|
||||
/// </summary>
|
||||
private static string? StripBoilerplate(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
|
||||
var cutpoints = new[] { "PRODUCT SUPPORT", "CUSTOMER SERVICE", "Q&As", "FAQs" };
|
||||
var cut = raw.Length;
|
||||
foreach (var cp in cutpoints)
|
||||
{
|
||||
var idx = raw.IndexOf(cp, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx > 0 && idx < cut)
|
||||
cut = idx;
|
||||
}
|
||||
|
||||
var cleaned = raw[..cut].Trim();
|
||||
// Collapse multiple whitespace runs
|
||||
cleaned = Regex.Replace(cleaned, @"\s{3,}", " ").Trim();
|
||||
return cleaned.Length > 0 ? cleaned : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the base (lowest-quantity) unit price from the price_tiers array.
|
||||
/// Falls back to 0 if the array is missing or malformed.
|
||||
/// </summary>
|
||||
private static decimal ExtractBasePrice(JsonElement item)
|
||||
{
|
||||
if (!item.TryGetProperty("price_tiers", out var tiers) || tiers.ValueKind != JsonValueKind.Array)
|
||||
return 0m;
|
||||
|
||||
// Find the tier with the lowest min (base price = smallest quantity break)
|
||||
decimal? price = null;
|
||||
int lowestMin = int.MaxValue;
|
||||
foreach (var tier in tiers.EnumerateArray())
|
||||
{
|
||||
if (tier.TryGetProperty("min", out var minEl) && minEl.TryGetInt32(out var min) &&
|
||||
tier.TryGetProperty("price", out var priceEl) && priceEl.TryGetDecimal(out var p))
|
||||
{
|
||||
if (min < lowestMin && min >= 1 && p > 0)
|
||||
{
|
||||
lowestMin = min;
|
||||
price = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return price ?? 0m;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Extension helpers for reading nullable strings from JsonElement.</summary>
|
||||
internal static class JsonElementExtensions
|
||||
{
|
||||
internal static string? GetStringOrNull(this JsonElement el, string property)
|
||||
{
|
||||
if (el.TryGetProperty(property, out var val) && val.ValueKind == JsonValueKind.String)
|
||||
return val.GetString();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user