From 217de2a5ab9fa82439c13ebe47354ecfdf5dbcdb Mon Sep 17 00:00:00 2001 From: SysAdmin Date: Mon, 6 Oct 2025 05:29:21 +0100 Subject: [PATCH] Feature: Human-Readable Text Format Product Import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented a new text-based import format for bulk product imports that is easier to read, write, and version control compared to CSV format. ## New Features ### Import Service (ProductImportService.cs) - Added `ImportFromHumanTextAsync()` - Main text format parser - Added `GenerateTemplateAsHumanText()` - Template generator - Parser supports: - Product blocks starting with `#` - Descriptions between `` tags (optional) - Key-value properties (category, price, weight, unit, stock) - Variants (lines starting with `-`) - Multi-buy offers (lines starting with `+`) - Variant collections (optional, after product name) ### Admin UI - New controller actions: - `ImportText()` - GET: Show import form - `ImportText(textContent, file)` - POST: Process import - `DownloadTextTemplate()` - Download .txt template - New view: `ImportText.cshtml` - Textarea for pasting text - File upload for .txt files - Format documentation sidebar - Links to CSV import and template downloads - Updated `Index.cshtml` with dropdown menu for import options ### Template & Documentation - Created `docs/ProductImportTemplate.txt` with 7 example products - Demonstrates all format features: - Products with/without descriptions - Variants with stock levels - Multi-buy pricing tiers - Multiple weight units ## Text Format Specification ``` # Product Name; OptionalVariantCollection Multi-line description (optional) category: CategoryName price: 10.00 weight: 100 unit: Grams stock: 50 - Variant1; 8.00; 50 - Variant2; 12.00; 30 + Multi-buy1; 2; 19.00 + Multi-buy2; 3; 25.00 ``` ## Benefits - ✅ Git-friendly (easy to diff and version) - ✅ Human-readable and editable - ✅ Supports all product features - ✅ Multi-line descriptions - ✅ Clear structure with # delimiters - ✅ Optional fields (description, variants, multi-buys) - ✅ Comprehensive error reporting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Admin/Controllers/ProductsController.cs | 52 +++ .../Admin/Views/Products/ImportText.cshtml | 148 ++++++++ .../Areas/Admin/Views/Products/Index.cshtml | 17 +- LittleShop/Services/ProductImportService.cs | 350 ++++++++++++++++++ docs/ProductImportTemplate.txt | 126 +++++++ 5 files changed, 690 insertions(+), 3 deletions(-) create mode 100644 LittleShop/Areas/Admin/Views/Products/ImportText.cshtml create mode 100644 docs/ProductImportTemplate.txt diff --git a/LittleShop/Areas/Admin/Controllers/ProductsController.cs b/LittleShop/Areas/Admin/Controllers/ProductsController.cs index 90ec2a6..f469527 100644 --- a/LittleShop/Areas/Admin/Controllers/ProductsController.cs +++ b/LittleShop/Areas/Admin/Controllers/ProductsController.cs @@ -244,6 +244,58 @@ public class ProductsController : Controller return File(Encoding.UTF8.GetBytes(templateContent), "text/csv", fileName); } + // Human-Readable Text Format Import + public IActionResult ImportText() + { + return View(); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ImportText(string textContent, IFormFile? file) + { + string importText = textContent; + + // If file uploaded, use file content instead of textarea + if (file != null && file.Length > 0) + { + if (!file.FileName.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + ModelState.AddModelError("", "Only .txt files are supported for text format import"); + return View(); + } + + using var reader = new StreamReader(file.OpenReadStream()); + importText = await reader.ReadToEndAsync(); + } + + if (string.IsNullOrWhiteSpace(importText)) + { + ModelState.AddModelError("", "Please provide text content or upload a .txt file"); + return View(); + } + + try + { + var result = await _importService.ImportFromHumanTextAsync(importText); + ViewData["ImportResult"] = result; + return View("ImportResult", result); + } + catch (Exception ex) + { + ModelState.AddModelError("", $"Import failed: {ex.Message}"); + return View(); + } + } + + public IActionResult DownloadTextTemplate() + { + var templateContent = _importService.GenerateTemplateAsHumanText(); + var fileName = "product_import_template.txt"; + + return File(Encoding.UTF8.GetBytes(templateContent), "text/plain", fileName); + } + [HttpGet] public async Task GetVariantCollection(Guid id) { diff --git a/LittleShop/Areas/Admin/Views/Products/ImportText.cshtml b/LittleShop/Areas/Admin/Views/Products/ImportText.cshtml new file mode 100644 index 0000000..f50254a --- /dev/null +++ b/LittleShop/Areas/Admin/Views/Products/ImportText.cshtml @@ -0,0 +1,148 @@ +@{ + ViewData["Title"] = "Import Products (Text Format)"; +} + +
+
+ +

Import Products (Human-Readable Text)

+

Bulk import products with variants and multi-buys using an easy-to-read text format

+
+
+ +
+
+
+
+
Paste or Upload Text
+
+
+
+ @Html.AntiForgeryToken() + + @if (!ViewData.ModelState.IsValid) + { +
+
    + @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +
  • @error.ErrorMessage
  • + } +
+
+ } + +
+ + +
+ Paste your product data in the human-readable format, or upload a .txt file below. +
+
+ +
+ + +
+ Upload a text file with product data (optional, overrides textarea content). + + Download template + +
+
+ +
+ + + Use CSV Format Instead + + + Cancel + +
+
+
+
+
+ +
+
+
+
Text Format Structure
+
+
+

Product Block (starts with #):

+
# Product Name; OptionalVariantCollection
+<text>
+Description here (optional)
+</text>
+category: CategoryName
+price: 10.00
+weight: 100
+unit: Grams
+stock: 50
+
+- Variant1; 8.00; 50
+- Variant2; 12.00; 30
+
++ Multi-buy1; 2; 19.00
++ Multi-buy2; 3; 25.00
+ +

Key Points:

+
    +
  • Each product starts with #
  • +
  • Description between <text> tags (optional)
  • +
  • category: is required
  • +
  • Variants start with -
  • +
  • Multi-buys start with +
  • +
+
+
+ +
+
+
Field Formats
+
+
+

Variant Line:

+ - Name; Price; StockLevel +

Example: - Small; 8.00; 50

+ +

Multi-Buy Line:

+ + Name; Quantity; Price +

Example: + Twin Pack; 2; 19.00

+ +

Weight Units:

+
    +
  • Grams (default)
  • +
  • Kilograms
  • +
  • Pounds
  • +
  • Ounces
  • +
+
+
+ +
+
+
Quick Actions
+
+ +
+
+
diff --git a/LittleShop/Areas/Admin/Views/Products/Index.cshtml b/LittleShop/Areas/Admin/Views/Products/Index.cshtml index c102b91..ce93c92 100644 --- a/LittleShop/Areas/Admin/Views/Products/Index.cshtml +++ b/LittleShop/Areas/Admin/Views/Products/Index.cshtml @@ -9,9 +9,20 @@

Products

diff --git a/LittleShop/Services/ProductImportService.cs b/LittleShop/Services/ProductImportService.cs index 691e479..4ca2ddf 100644 --- a/LittleShop/Services/ProductImportService.cs +++ b/LittleShop/Services/ProductImportService.cs @@ -12,7 +12,9 @@ public interface IProductImportService { Task ImportFromCsvAsync(Stream csvStream); Task ImportFromTextAsync(string csvText); + Task ImportFromHumanTextAsync(string textContent); string GenerateTemplateAsCsv(); + string GenerateTemplateAsHumanText(); Task ExportProductsAsCsvAsync(); } @@ -327,4 +329,352 @@ public class ProductImportService : IProductImportService } return defaultValue; } + + // ===== HUMAN-READABLE TEXT FORMAT IMPORT ===== + + public async Task ImportFromHumanTextAsync(string textContent) + { + var result = new ProductImportResultDto(); + + if (string.IsNullOrWhiteSpace(textContent)) + { + result.Errors.Add(new ProductImportErrorDto + { + RowNumber = 0, + ProductName = "File", + ErrorMessages = { "Import text is empty" } + }); + return result; + } + + // Get categories and variant collections for lookup + var categories = await _categoryService.GetAllCategoriesAsync(); + var categoryLookup = categories.ToDictionary(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase); + + var variantCollectionsList = await _context.VariantCollections + .Where(vc => vc.IsActive) + .ToListAsync(); + var variantCollections = variantCollectionsList.ToDictionary(vc => vc.Name, vc => vc.Id, StringComparer.OrdinalIgnoreCase); + + // Split into product blocks (each starting with #) + var productBlocks = SplitIntoProductBlocks(textContent); + result.TotalRows = productBlocks.Count; + + for (int i = 0; i < productBlocks.Count; i++) + { + try + { + await ParseAndImportProductBlock(productBlocks[i], categoryLookup, variantCollections, result, i + 1); + } + catch (Exception ex) + { + result.Errors.Add(new ProductImportErrorDto + { + RowNumber = i + 1, + ProductName = "Parse Error", + ErrorMessages = { ex.Message } + }); + result.FailedImports++; + } + } + + _logger.LogInformation("Human text import completed: {Success} successful, {Failed} failed", + result.SuccessfulImports, result.FailedImports); + return result; + } + + private List SplitIntoProductBlocks(string textContent) + { + var blocks = new List(); + var lines = textContent.Split('\n'); + var currentBlock = new StringBuilder(); + + foreach (var line in lines) + { + if (line.TrimStart().StartsWith("#") && currentBlock.Length > 0) + { + // Start of new product block, save current block + blocks.Add(currentBlock.ToString()); + currentBlock.Clear(); + } + currentBlock.AppendLine(line); + } + + // Add final block + if (currentBlock.Length > 0) + { + blocks.Add(currentBlock.ToString()); + } + + return blocks; + } + + private async Task ParseAndImportProductBlock( + string blockText, + Dictionary categoryLookup, + Dictionary variantCollectionLookup, + ProductImportResultDto result, + int blockNumber) + { + var lines = blockText.Split('\n', StringSplitOptions.RemoveEmptyEntries); + var errors = new List(); + + // Parse product header (first line starting with #) + var headerLine = lines.FirstOrDefault(l => l.TrimStart().StartsWith("#")); + if (headerLine == null) + { + errors.Add("Product block must start with # followed by product name"); + result.Errors.Add(new ProductImportErrorDto + { + RowNumber = blockNumber, + ProductName = "Unknown", + ErrorMessages = errors + }); + result.FailedImports++; + return; + } + + // Extract product name and optional variant collection + var headerParts = headerLine.TrimStart('#').Split(';'); + var productName = headerParts[0].Trim(); + string? variantCollectionName = headerParts.Length > 1 ? headerParts[1].Trim() : null; + + // Extract description (between and ) + var description = ExtractTextBlock(blockText); + + // Parse key-value properties + var properties = ParseKeyValueProperties(lines); + + // Extract category + if (!properties.TryGetValue("category", out var categoryName)) + { + errors.Add("Missing required 'category:' field"); + } + else if (!categoryLookup.TryGetValue(categoryName, out var categoryId)) + { + errors.Add($"Category '{categoryName}' not found"); + } + + // Validate variant collection if specified + Guid? variantCollectionId = null; + if (!string.IsNullOrEmpty(variantCollectionName)) + { + if (!variantCollectionLookup.TryGetValue(variantCollectionName, out var vcId)) + { + errors.Add($"Variant collection '{variantCollectionName}' not found"); + } + else + { + variantCollectionId = vcId; + } + } + + // Parse numeric properties with defaults + var price = ParseDecimal(properties.GetValueOrDefault("price", "0"), "price"); + var weight = ParseDecimal(properties.GetValueOrDefault("weight", "0"), "weight"); + var stock = ParseInt(properties.GetValueOrDefault("stock", "0"), "stock"); + var weightUnitStr = properties.GetValueOrDefault("unit", "Grams"); + + if (!Enum.TryParse(weightUnitStr, true, out var weightUnit)) + { + errors.Add($"Invalid weight unit: {weightUnitStr}. Valid values: Grams, Kilograms, Pounds, Ounces"); + } + + if (errors.Any()) + { + result.Errors.Add(new ProductImportErrorDto + { + RowNumber = blockNumber, + ProductName = productName, + ErrorMessages = errors + }); + result.FailedImports++; + return; + } + + // Create product + var createProductDto = new CreateProductDto + { + Name = productName, + Description = description ?? "", + Price = price, + Weight = weight, + WeightUnit = weightUnit, + StockQuantity = stock, + CategoryId = categoryLookup[categoryName], + VariantCollectionId = variantCollectionId + }; + + var product = await _productService.CreateProductAsync(createProductDto); + result.ImportedProducts.Add(product); + + // Parse and import variants (lines starting with -) + var variantLines = lines.Where(l => l.TrimStart().StartsWith("-")).ToList(); + for (int i = 0; i < variantLines.Count; i++) + { + await ParseAndImportVariantLine(product.Id, variantLines[i], i); + } + + // Parse and import multi-buys (lines starting with +) + var multiBuyLines = lines.Where(l => l.TrimStart().StartsWith("+")).ToList(); + for (int i = 0; i < multiBuyLines.Count; i++) + { + await ParseAndImportMultiBuyLine(product.Id, multiBuyLines[i], i); + } + + result.SuccessfulImports++; + } + + private string? ExtractTextBlock(string blockText) + { + var startTag = ""; + var endTag = ""; + + var startIndex = blockText.IndexOf(startTag, StringComparison.OrdinalIgnoreCase); + if (startIndex == -1) return null; + + startIndex += startTag.Length; + var endIndex = blockText.IndexOf(endTag, startIndex, StringComparison.OrdinalIgnoreCase); + if (endIndex == -1) return null; + + return blockText.Substring(startIndex, endIndex - startIndex).Trim(); + } + + private Dictionary ParseKeyValueProperties(string[] lines) + { + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("#") || trimmed.StartsWith("<") || + trimmed.StartsWith("-") || trimmed.StartsWith("+")) + continue; + + var colonIndex = trimmed.IndexOf(':'); + if (colonIndex > 0) + { + var key = trimmed.Substring(0, colonIndex).Trim(); + var value = trimmed.Substring(colonIndex + 1).Trim(); + properties[key] = value; + } + } + + return properties; + } + + private async Task ParseAndImportVariantLine(Guid productId, string line, int sortOrder) + { + // Format: - VariantName; Price; OptionalStockLevel + // Example: - Small; 8.00; 50 + var content = line.TrimStart('-').Trim(); + var parts = content.Split(';').Select(p => p.Trim()).ToArray(); + + if (parts.Length < 2) return; // Need at least name and price + + var variantName = parts[0]; + var price = ParseDecimal(parts[1], "variant price"); + var stockLevel = parts.Length > 2 ? ParseInt(parts[2], "variant stock") : 0; + + var variantDto = new CreateProductVariantDto + { + ProductId = productId, + Name = variantName, + Price = price, + StockLevel = stockLevel, + SortOrder = sortOrder, + VariantType = "Standard" + }; + + await _productService.CreateProductVariantAsync(variantDto); + } + + private async Task ParseAndImportMultiBuyLine(Guid productId, string line, int sortOrder) + { + // Format: + Name; Quantity; Price + // Example: + Twin Pack; 2; 19.00 + var content = line.TrimStart('+').Trim(); + var parts = content.Split(';').Select(p => p.Trim()).ToArray(); + + if (parts.Length < 3) return; // Need name, quantity, and price + + var name = parts[0]; + var quantity = ParseInt(parts[1], "multi-buy quantity"); + var price = ParseDecimal(parts[2], "multi-buy price"); + + var multiBuyDto = new CreateProductMultiBuyDto + { + ProductId = productId, + Name = name, + Description = "", + Quantity = quantity, + Price = price, + SortOrder = sortOrder + }; + + await _productService.CreateProductMultiBuyAsync(multiBuyDto); + } + + private decimal ParseDecimal(string value, string fieldName) + { + if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) + return result; + throw new FormatException($"Invalid decimal value for {fieldName}: {value}"); + } + + private int ParseInt(string value, string fieldName) + { + if (int.TryParse(value, out var result)) + return result; + throw new FormatException($"Invalid integer value for {fieldName}: {value}"); + } + + public string GenerateTemplateAsHumanText() + { + return @"# Organic Coffee Beans; Size Options + +Premium organic coffee beans sourced from sustainable farms. +Perfect for espresso or filter coffee. +Freshly roasted to order. + +category: Beverages +price: 12.00 +weight: 250 +unit: Grams +stock: 100 + +- 250g; 12.00; 50 +- 500g; 22.00; 30 +- 1kg; 40.00; 20 + ++ Single Bag; 1; 12.00 ++ Twin Pack; 2; 22.00 ++ Family Pack; 3; 32.00 + +# Artisan Chocolate Bar + +Handcrafted dark chocolate made with 70% cacao. +Vegan and gluten-free. + +category: Confectionery +price: 3.50 +weight: 100 +unit: Grams +stock: 200 + ++ Single Bar; 1; 3.50 ++ Box of 6; 6; 18.00 + +# Premium Tea Blend +category: Beverages +price: 8.50 +weight: 50 +unit: Grams +stock: 75 + +- 50g Tin; 8.50; 40 +- 100g Tin; 15.00; 25 +- 250g Bulk; 32.00; 10 +"; + } } \ No newline at end of file diff --git a/docs/ProductImportTemplate.txt b/docs/ProductImportTemplate.txt new file mode 100644 index 0000000..ee7a5d0 --- /dev/null +++ b/docs/ProductImportTemplate.txt @@ -0,0 +1,126 @@ +# Product Import Template (Human-Readable Text Format) +# +# This template demonstrates how to import products using the human-readable text format. +# +# Format: +# - Each product block starts with # followed by product name +# - Optional variant collection name after semicolon: # Product Name; Variant Collection +# - Description between and tags (optional) +# - Required fields: category, price, weight, unit, stock +# - Variants: lines starting with - (hyphen) +# - Multi-buys: lines starting with + (plus) +# +# Delete these comment lines before importing! + +# Organic Coffee Beans; Size Options + +Premium organic coffee beans sourced from sustainable farms. +Perfect for espresso or filter coffee. +Freshly roasted to order for maximum flavor and aroma. + +category: Beverages +price: 12.00 +weight: 250 +unit: Grams +stock: 100 + +- 250g; 12.00; 50 +- 500g; 22.00; 30 +- 1kg; 40.00; 20 + ++ Single Bag; 1; 12.00 ++ Twin Pack; 2; 22.00 ++ Family Pack; 3; 32.00 + +# Artisan Chocolate Bar + +Handcrafted dark chocolate made with 70% cacao. +Vegan and gluten-free. +Made in small batches for exceptional quality. + +category: Confectionery +price: 3.50 +weight: 100 +unit: Grams +stock: 200 + ++ Single Bar; 1; 3.50 ++ Box of 6; 6; 18.00 ++ Box of 12; 12; 35.00 + +# Premium Tea Blend + +A harmonious blend of black tea, bergamot, and lavender. +Perfect for afternoon tea or as a calming evening drink. + +category: Beverages +price: 8.50 +weight: 50 +unit: Grams +stock: 75 + +- 50g Tin; 8.50; 40 +- 100g Tin; 15.00; 25 +- 250g Bulk; 32.00; 10 + ++ Single Tin; 1; 8.50 ++ Tea Duo; 2; 16.00 + +# Natural Honey + +Raw, unfiltered honey from local beekeepers. +Rich in antioxidants and natural enzymes. +Perfect for sweetening tea or spreading on toast. + +category: Pantry Staples +price: 9.99 +weight: 340 +unit: Grams +stock: 150 + +- 340g Jar; 9.99; 80 +- 680g Jar; 17.99; 50 +- 1kg Bulk; 24.99; 20 + +# Handmade Soap Bar +category: Personal Care +price: 5.50 +weight: 120 +unit: Grams +stock: 300 + +- Lavender; 5.50; 100 +- Peppermint; 5.50; 100 +- Eucalyptus; 5.50; 100 + ++ Single Bar; 1; 5.50 ++ Gift Set of 3; 3; 15.00 + +# Organic Olive Oil + +Extra virgin olive oil from family-owned groves in Italy. +Cold-pressed for superior quality and flavor. +Ideal for cooking, salads, and dipping. + +category: Pantry Staples +price: 14.99 +weight: 500 +unit: Grams +stock: 60 + ++ 500ml Bottle; 1; 14.99 ++ Twin Pack; 2; 27.99 + +# Gourmet Popcorn +category: Snacks +price: 4.25 +weight: 200 +unit: Grams +stock: 180 + +- Sweet & Salty; 4.25; 60 +- Caramel; 4.25; 60 +- Cheese; 4.25; 60 + ++ Single Pack; 1; 4.25 ++ Variety Pack (3); 3; 11.99