littleshop/LittleShop/Services/ProductImportService.cs
SysAdmin 217de2a5ab Feature: Human-Readable Text Format Product Import
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 `<text>` 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
<text>
Multi-line description (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
```

## 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 <noreply@anthropic.com>
2025-10-06 05:29:21 +01:00

680 lines
23 KiB
C#

using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Text;
using LittleShop.Data;
using LittleShop.Models;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Services;
public interface IProductImportService
{
Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream);
Task<ProductImportResultDto> ImportFromTextAsync(string csvText);
Task<ProductImportResultDto> ImportFromHumanTextAsync(string textContent);
string GenerateTemplateAsCsv();
string GenerateTemplateAsHumanText();
Task<string> ExportProductsAsCsvAsync();
}
public class ProductImportService : IProductImportService
{
private readonly LittleShopContext _context;
private readonly IProductService _productService;
private readonly ICategoryService _categoryService;
private readonly ILogger<ProductImportService> _logger;
public ProductImportService(
LittleShopContext context,
IProductService productService,
ICategoryService categoryService,
ILogger<ProductImportService> logger)
{
_context = context;
_productService = productService;
_categoryService = categoryService;
_logger = logger;
}
public async Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream)
{
using var reader = new StreamReader(csvStream);
var csvText = await reader.ReadToEndAsync();
return await ImportFromTextAsync(csvText);
}
public async Task<ProductImportResultDto> ImportFromTextAsync(string csvText)
{
var result = new ProductImportResultDto();
var lines = csvText.Split('\n', StringSplitOptions.RemoveEmptyEntries);
if (lines.Length == 0)
{
result.Errors.Add(new ProductImportErrorDto
{
RowNumber = 0,
ProductName = "File",
ErrorMessages = { "CSV file is empty" }
});
return result;
}
// Parse header
var headers = ParseCsvLine(lines[0]);
var expectedHeaders = new[] { "Name", "Description", "Price", "Weight", "WeightUnit", "StockQuantity", "CategoryName", "IsActive", "Variations", "PhotoUrls" };
// Validate headers
foreach (var expectedHeader in expectedHeaders.Take(7)) // First 7 are required
{
if (!headers.Contains(expectedHeader, StringComparer.OrdinalIgnoreCase))
{
result.Errors.Add(new ProductImportErrorDto
{
RowNumber = 0,
ProductName = "Header",
ErrorMessages = { $"Missing required column: {expectedHeader}" }
});
}
}
if (result.Errors.Any())
return result;
result.TotalRows = lines.Length - 1; // Exclude header
// Get categories for lookup
var categories = await _categoryService.GetAllCategoriesAsync();
var categoryLookup = categories.ToDictionary(c => c.Name, c => c.Id, StringComparer.OrdinalIgnoreCase);
// Process data rows
for (int i = 1; i < lines.Length; i++)
{
try
{
var values = ParseCsvLine(lines[i]);
if (values.Length < 7) // Minimum required columns
{
result.Errors.Add(new ProductImportErrorDto
{
RowNumber = i + 1,
ProductName = values.Length > 0 ? values[0] : "Unknown",
ErrorMessages = { "Insufficient columns in row" }
});
result.FailedImports++;
continue;
}
var importDto = new ProductImportDto
{
Name = GetValue(values, headers, "Name", ""),
Description = GetValue(values, headers, "Description", ""),
Price = decimal.Parse(GetValue(values, headers, "Price", "0"), CultureInfo.InvariantCulture),
Weight = decimal.Parse(GetValue(values, headers, "Weight", "0"), CultureInfo.InvariantCulture),
WeightUnit = GetValue(values, headers, "WeightUnit", "Grams"),
StockQuantity = int.Parse(GetValue(values, headers, "StockQuantity", "0")),
CategoryName = GetValue(values, headers, "CategoryName", ""),
IsActive = bool.Parse(GetValue(values, headers, "IsActive", "true")),
Variations = GetValue(values, headers, "Variations", null),
PhotoUrls = GetValue(values, headers, "PhotoUrls", null)
};
await ImportSingleProductAsync(importDto, categoryLookup, result, i + 1);
}
catch (Exception ex)
{
result.Errors.Add(new ProductImportErrorDto
{
RowNumber = i + 1,
ProductName = "Parse Error",
ErrorMessages = { ex.Message }
});
result.FailedImports++;
}
}
_logger.LogInformation("Product import completed: {Success} successful, {Failed} failed", result.SuccessfulImports, result.FailedImports);
return result;
}
private async Task ImportSingleProductAsync(ProductImportDto importDto, Dictionary<string, Guid> categoryLookup, ProductImportResultDto result, int rowNumber)
{
var errors = new List<string>();
// Validate category
if (!categoryLookup.TryGetValue(importDto.CategoryName, out var categoryId))
{
errors.Add($"Category '{importDto.CategoryName}' not found");
}
// Validate weight unit
if (!Enum.TryParse<ProductWeightUnit>(importDto.WeightUnit, true, out var weightUnit))
{
errors.Add($"Invalid weight unit: {importDto.WeightUnit}");
}
if (errors.Any())
{
result.Errors.Add(new ProductImportErrorDto
{
RowNumber = rowNumber,
ProductName = importDto.Name,
ErrorMessages = errors
});
result.FailedImports++;
return;
}
try
{
// Create product
var createProductDto = new CreateProductDto
{
Name = importDto.Name,
Description = importDto.Description,
Price = importDto.Price,
Weight = importDto.Weight,
WeightUnit = weightUnit,
StockQuantity = importDto.StockQuantity,
CategoryId = categoryId
};
var product = await _productService.CreateProductAsync(createProductDto);
result.ImportedProducts.Add(product);
// Import variations if provided
if (!string.IsNullOrEmpty(importDto.Variations))
{
await ImportProductMultiBuysAsync(product.Id, importDto.Variations);
}
// Import photos if provided
if (!string.IsNullOrEmpty(importDto.PhotoUrls))
{
await ImportProductPhotosAsync(product.Id, importDto.PhotoUrls);
}
result.SuccessfulImports++;
}
catch (Exception ex)
{
result.Errors.Add(new ProductImportErrorDto
{
RowNumber = rowNumber,
ProductName = importDto.Name,
ErrorMessages = { ex.Message }
});
result.FailedImports++;
}
}
private async Task ImportProductMultiBuysAsync(Guid productId, string variationsText)
{
// Format: "Single Item:1:10.00;Twin Pack:2:19.00;Triple Pack:3:25.00"
var variations = variationsText.Split(';', StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < variations.Length; i++)
{
var parts = variations[i].Split(':', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 3)
{
var multiBuyDto = new CreateProductMultiBuyDto
{
ProductId = productId,
Name = parts[0].Trim(),
Description = parts.Length > 3 ? parts[3].Trim() : "",
Quantity = int.Parse(parts[1].Trim()),
Price = decimal.Parse(parts[2].Trim(), CultureInfo.InvariantCulture),
SortOrder = i
};
await _productService.CreateProductMultiBuyAsync(multiBuyDto);
}
}
}
private async Task ImportProductPhotosAsync(Guid productId, string photoUrlsText)
{
// Format: "url1;url2;url3"
var urls = photoUrlsText.Split(';', StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < urls.Length; i++)
{
var photoDto = new CreateProductPhotoDto
{
ProductId = productId,
PhotoUrl = urls[i].Trim(),
DisplayOrder = i + 1
};
await _productService.AddProductPhotoAsync(photoDto);
}
}
public string GenerateTemplateAsCsv()
{
var sb = new StringBuilder();
// Header
sb.AppendLine("Name,Description,Price,Weight,WeightUnit,StockQuantity,CategoryName,IsActive,Variations,PhotoUrls");
// Example rows
sb.AppendLine("\"Example Product 1\",\"High-quality example product with great features\",29.99,150,Grams,50,Electronics,true,\"Single Item:1:29.99;Twin Pack:2:55.00;Triple Pack:3:79.99\",\"https://example.com/photo1.jpg;https://example.com/photo2.jpg\"");
sb.AppendLine("\"Example Product 2\",\"Another sample product for import testing\",19.99,200,Grams,25,Clothing,true,\"Individual:1:19.99;Pair:2:35.00\",\"\"");
sb.AppendLine("\"Simple Product\",\"Basic product without variations\",9.99,100,Grams,100,Books,true,\"\",\"\"");
return sb.ToString();
}
public async Task<string> ExportProductsAsCsvAsync()
{
var products = await _productService.GetAllProductsAsync();
var sb = new StringBuilder();
// Header
sb.AppendLine("Name,Description,Price,Weight,WeightUnit,StockQuantity,CategoryName,IsActive,Variations,PhotoUrls");
foreach (var product in products)
{
// Build variations string
var variationsText = string.Join(";", product.MultiBuys.OrderBy(v => v.SortOrder)
.Select(v => $"{v.Name}:{v.Quantity}:{v.Price:F2}"));
// Build photo URLs string
var photoUrlsText = string.Join(";", product.Photos.OrderBy(p => p.SortOrder)
.Select(p => p.FilePath));
sb.AppendLine($"\"{product.Name}\",\"{product.Description}\",{product.Price:F2},{product.Weight:F2},{product.WeightUnit},{product.StockQuantity},\"{product.CategoryName}\",{product.IsActive.ToString().ToLower()},\"{variationsText}\",\"{photoUrlsText}\"");
}
return sb.ToString();
}
private string[] ParseCsvLine(string line)
{
var values = new List<string>();
var inQuotes = false;
var currentValue = new StringBuilder();
for (int i = 0; i < line.Length; i++)
{
var c = line[i];
if (c == '"')
{
inQuotes = !inQuotes;
}
else if (c == ',' && !inQuotes)
{
values.Add(currentValue.ToString().Trim());
currentValue.Clear();
}
else
{
currentValue.Append(c);
}
}
values.Add(currentValue.ToString().Trim());
return values.ToArray();
}
private string GetValue(string[] values, string[] headers, string columnName, string defaultValue)
{
var index = Array.FindIndex(headers, h => h.Equals(columnName, StringComparison.OrdinalIgnoreCase));
if (index >= 0 && index < values.Length)
{
var value = values[index].Trim('"', ' ');
return string.IsNullOrEmpty(value) ? defaultValue : value;
}
return defaultValue;
}
// ===== HUMAN-READABLE TEXT FORMAT IMPORT =====
public async Task<ProductImportResultDto> 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<string> SplitIntoProductBlocks(string textContent)
{
var blocks = new List<string>();
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<string, Guid> categoryLookup,
Dictionary<string, Guid> variantCollectionLookup,
ProductImportResultDto result,
int blockNumber)
{
var lines = blockText.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var errors = new List<string>();
// 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 <text> and </text>)
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<ProductWeightUnit>(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 = "<text>";
var endTag = "</text>";
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<string, string> ParseKeyValueProperties(string[] lines)
{
var properties = new Dictionary<string, string>(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
<text>
Premium organic coffee beans sourced from sustainable farms.
Perfect for espresso or filter coffee.
Freshly roasted to order.
</text>
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
<text>
Handcrafted dark chocolate made with 70% cacao.
Vegan and gluten-free.
</text>
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
";
}
}