using System.Globalization; using LudosData.Api.Services; namespace LudosData.Api.Services.Pricing; /// One row of an external price list. public record PriceGuideRow( string Title, string? System, decimal? Loose, decimal? Cib, decimal? New); public record PriceGuideParseResult( IReadOnlyList Rows, IReadOnlyList Problems); /// /// Reads a price list out of a CSV. /// /// The point is to be source-agnostic. A PriceCharting subscriber's bulk /// download, a spreadsheet kept by hand, and a list exported from anywhere else /// all describe the same thing — a title, a platform and some prices — so this /// accepts the column names each of them tends to use rather than demanding one /// fixed schema. /// public static class PriceGuide { // Header aliases, lowercased and stripped of spaces, underscores and hyphens. private static readonly string[] TitleNames = ["title", "productname", "product", "name", "game", "gamename"]; private static readonly string[] SystemNames = ["system", "console", "consolename", "platform"]; private static readonly string[] LooseNames = ["loose", "looseprice", "loosevalue", "cartonly", "value"]; private static readonly string[] CibNames = ["cib", "cibprice", "complete", "completeprice", "cibvalue", "completeinbox"]; private static readonly string[] NewNames = ["new", "newprice", "sealed", "sealedprice", "newvalue", "graded"]; private static string Normalise(string header) => new(header.Trim().ToLowerInvariant() .Where(c => char.IsLetterOrDigit(c)) .ToArray()); public static PriceGuideParseResult Parse(string text) { var problems = new List(); var rows = Csv.Parse(text); if (rows.Count == 0) { return new PriceGuideParseResult([], ["The file is empty."]); } var header = rows[0].Select(Normalise).ToList(); int Find(string[] names) { foreach (var name in names) { var at = header.IndexOf(name); if (at >= 0) return at; } return -1; } var titleAt = Find(TitleNames); if (titleAt < 0) { return new PriceGuideParseResult([], ["No title column found. Expected one of: title, product-name, name, game."]); } var systemAt = Find(SystemNames); var looseAt = Find(LooseNames); var cibAt = Find(CibNames); var newAt = Find(NewNames); if (looseAt < 0 && cibAt < 0 && newAt < 0) { return new PriceGuideParseResult([], ["No price column found. Expected at least one of: loose, cib, new."]); } var parsed = new List(); for (var i = 1; i < rows.Count; i++) { var row = rows[i]; string? Cell(int at) => at >= 0 && at < row.Count && row[at].Trim().Length > 0 ? row[at].Trim() : null; var title = Cell(titleAt); if (title is null) { problems.Add($"Row {i + 1}: no title"); continue; } parsed.Add(new PriceGuideRow( title, Cell(systemAt), ParseMoney(Cell(looseAt)), ParseMoney(Cell(cibAt)), ParseMoney(Cell(newAt)))); } return new PriceGuideParseResult(parsed, problems); } /// /// Tolerant of what a spreadsheet emits: currency symbols, thousands /// separators, and integer pennies where a guide quotes them that way. /// public static decimal? ParseMoney(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty).Trim(); if (!decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)) { return null; } // Zero means "no price on record", not "free". return parsed > 0 ? parsed : null; } }