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>
This commit is contained in:
parent
d897bb99c3
commit
217de2a5ab
@ -244,6 +244,58 @@ public class ProductsController : Controller
|
|||||||
return File(Encoding.UTF8.GetBytes(templateContent), "text/csv", fileName);
|
return File(Encoding.UTF8.GetBytes(templateContent), "text/csv", fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human-Readable Text Format Import
|
||||||
|
public IActionResult ImportText()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> 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]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetVariantCollection(Guid id)
|
public async Task<IActionResult> GetVariantCollection(Guid id)
|
||||||
{
|
{
|
||||||
|
|||||||
148
LittleShop/Areas/Admin/Views/Products/ImportText.cshtml
Normal file
148
LittleShop/Areas/Admin/Views/Products/ImportText.cshtml
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Import Products (Text Format)";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="@Url.Action("Index")">Products</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">Import (Text Format)</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<h1><i class="fas fa-file-alt"></i> Import Products (Human-Readable Text)</h1>
|
||||||
|
<p class="text-muted">Bulk import products with variants and multi-buys using an easy-to-read text format</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Paste or Upload Text</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" enctype="multipart/form-data" asp-action="ImportText">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
|
||||||
|
@if (!ViewData.ModelState.IsValid)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<ul class="mb-0">
|
||||||
|
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
|
||||||
|
{
|
||||||
|
<li>@error.ErrorMessage</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="textContent" class="form-label">Product Import Text</label>
|
||||||
|
<textarea name="textContent" id="textContent" class="form-control font-monospace" rows="15" placeholder="Paste your product import text here..."></textarea>
|
||||||
|
<div class="form-text">
|
||||||
|
Paste your product data in the human-readable format, or upload a .txt file below.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="file" class="form-label">Or Upload .txt File</label>
|
||||||
|
<input type="file" name="file" id="file" class="form-control" accept=".txt" />
|
||||||
|
<div class="form-text">
|
||||||
|
Upload a text file with product data (optional, overrides textarea content).
|
||||||
|
<a href="@Url.Action("DownloadTextTemplate")" class="text-decoration-none">
|
||||||
|
<i class="fas fa-download"></i> Download template
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="fas fa-upload"></i> Import Products
|
||||||
|
</button>
|
||||||
|
<a href="@Url.Action("Import")" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-file-csv"></i> Use CSV Format Instead
|
||||||
|
</a>
|
||||||
|
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-times"></i> Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="card-title mb-0">Text Format Structure</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p><strong>Product Block (starts with #):</strong></p>
|
||||||
|
<pre class="small"><code># 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</code></pre>
|
||||||
|
|
||||||
|
<p class="mt-3"><strong>Key Points:</strong></p>
|
||||||
|
<ul class="small">
|
||||||
|
<li>Each product starts with <code>#</code></li>
|
||||||
|
<li>Description between <code><text></code> tags (optional)</li>
|
||||||
|
<li><code>category:</code> is required</li>
|
||||||
|
<li>Variants start with <code>-</code></li>
|
||||||
|
<li>Multi-buys start with <code>+</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="card-title mb-0">Field Formats</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small"><strong>Variant Line:</strong></p>
|
||||||
|
<code class="small">- Name; Price; StockLevel</code>
|
||||||
|
<p class="small mt-1">Example: <code>- Small; 8.00; 50</code></p>
|
||||||
|
|
||||||
|
<p class="small mt-3"><strong>Multi-Buy Line:</strong></p>
|
||||||
|
<code class="small">+ Name; Quantity; Price</code>
|
||||||
|
<p class="small mt-1">Example: <code>+ Twin Pack; 2; 19.00</code></p>
|
||||||
|
|
||||||
|
<p class="small mt-3"><strong>Weight Units:</strong></p>
|
||||||
|
<ul class="small mb-0">
|
||||||
|
<li><code>Grams</code> (default)</li>
|
||||||
|
<li><code>Kilograms</code></li>
|
||||||
|
<li><code>Pounds</code></li>
|
||||||
|
<li><code>Ounces</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="card-title mb-0">Quick Actions</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="@Url.Action("DownloadTextTemplate")" class="btn btn-outline-info btn-sm">
|
||||||
|
<i class="fas fa-download"></i> Download Template
|
||||||
|
</a>
|
||||||
|
<a href="@Url.Action("Import")" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="fas fa-file-csv"></i> Switch to CSV Import
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -9,9 +9,20 @@
|
|||||||
<h1><i class="fas fa-box"></i> Products</h1>
|
<h1><i class="fas fa-box"></i> Products</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<a href="@Url.Action("Create")" class="btn btn-primary">
|
<div class="btn-group" role="group">
|
||||||
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Add Product</span>
|
<a href="@Url.Action("Create")" class="btn btn-primary">
|
||||||
</a>
|
<i class="fas fa-plus"></i> <span class="d-none d-sm-inline">Add Product</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fas fa-file-import"></i> <span class="d-none d-sm-inline">Import</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="@Url.Action("ImportText")"><i class="fas fa-file-alt"></i> Text Format</a></li>
|
||||||
|
<li><a class="dropdown-item" href="@Url.Action("Import")"><i class="fas fa-file-csv"></i> CSV Format</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="@Url.Action("Export")"><i class="fas fa-file-export"></i> Export Products</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,9 @@ public interface IProductImportService
|
|||||||
{
|
{
|
||||||
Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream);
|
Task<ProductImportResultDto> ImportFromCsvAsync(Stream csvStream);
|
||||||
Task<ProductImportResultDto> ImportFromTextAsync(string csvText);
|
Task<ProductImportResultDto> ImportFromTextAsync(string csvText);
|
||||||
|
Task<ProductImportResultDto> ImportFromHumanTextAsync(string textContent);
|
||||||
string GenerateTemplateAsCsv();
|
string GenerateTemplateAsCsv();
|
||||||
|
string GenerateTemplateAsHumanText();
|
||||||
Task<string> ExportProductsAsCsvAsync();
|
Task<string> ExportProductsAsCsvAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,4 +329,352 @@ public class ProductImportService : IProductImportService
|
|||||||
}
|
}
|
||||||
return defaultValue;
|
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
|
||||||
|
";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
126
docs/ProductImportTemplate.txt
Normal file
126
docs/ProductImportTemplate.txt
Normal file
@ -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 <text> and </text> 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
|
||||||
|
<text>
|
||||||
|
Premium organic coffee beans sourced from sustainable farms.
|
||||||
|
Perfect for espresso or filter coffee.
|
||||||
|
Freshly roasted to order for maximum flavor and aroma.
|
||||||
|
</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.
|
||||||
|
Made in small batches for exceptional quality.
|
||||||
|
</text>
|
||||||
|
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
|
||||||
|
<text>
|
||||||
|
A harmonious blend of black tea, bergamot, and lavender.
|
||||||
|
Perfect for afternoon tea or as a calming evening drink.
|
||||||
|
</text>
|
||||||
|
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
|
||||||
|
<text>
|
||||||
|
Raw, unfiltered honey from local beekeepers.
|
||||||
|
Rich in antioxidants and natural enzymes.
|
||||||
|
Perfect for sweetening tea or spreading on toast.
|
||||||
|
</text>
|
||||||
|
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
|
||||||
|
<text>
|
||||||
|
Extra virgin olive oil from family-owned groves in Italy.
|
||||||
|
Cold-pressed for superior quality and flavor.
|
||||||
|
Ideal for cooking, salads, and dipping.
|
||||||
|
</text>
|
||||||
|
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
|
||||||
Loading…
Reference in New Issue
Block a user