* Extended functionalities
* Added different UIs
This commit is contained in:
306
MealPlan.Api/Services/RecipeExcelService.cs
Normal file
306
MealPlan.Api/Services/RecipeExcelService.cs
Normal file
@@ -0,0 +1,306 @@
|
||||
using ClosedXML.Excel;
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Imports recipes from a three-sheet Excel workbook (Recipes, Ingredients, Steps).
|
||||
/// Also generates a downloadable template in the same format.
|
||||
/// </summary>
|
||||
public class RecipeExcelService : IRecipeExcelService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<RecipeExcelService> _logger;
|
||||
|
||||
public RecipeExcelService(AppDbContext db, ILogger<RecipeExcelService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public byte[] GenerateTemplate()
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
|
||||
var recipes = workbook.Worksheets.Add("Recipes");
|
||||
recipes.Cell(1, 1).Value = "Name";
|
||||
recipes.Cell(1, 2).Value = "MealCategory";
|
||||
recipes.Cell(1, 3).Value = "Calories";
|
||||
recipes.Cell(1, 4).Value = "Protein";
|
||||
recipes.Cell(1, 5).Value = "Fat";
|
||||
recipes.Cell(1, 6).Value = "Carbs";
|
||||
recipes.Cell(1, 7).Value = "PrepTimeMinutes";
|
||||
recipes.Row(1).Style.Font.Bold = true;
|
||||
recipes.Cell(2, 1).Value = "Oatmeal with berries";
|
||||
recipes.Cell(2, 2).Value = "Breakfast";
|
||||
recipes.Cell(2, 3).Value = 320;
|
||||
recipes.Cell(2, 4).Value = 12;
|
||||
recipes.Cell(2, 5).Value = 8;
|
||||
recipes.Cell(2, 6).Value = 45;
|
||||
recipes.Cell(2, 7).Value = 10;
|
||||
recipes.Columns().AdjustToContents();
|
||||
|
||||
var ingredients = workbook.Worksheets.Add("Ingredients");
|
||||
ingredients.Cell(1, 1).Value = "RecipeName";
|
||||
ingredients.Cell(1, 2).Value = "Name";
|
||||
ingredients.Cell(1, 3).Value = "AmountGrams";
|
||||
ingredients.Cell(1, 4).Value = "Unit";
|
||||
ingredients.Cell(1, 5).Value = "SortOrder";
|
||||
ingredients.Row(1).Style.Font.Bold = true;
|
||||
ingredients.Cell(2, 1).Value = "Oatmeal with berries";
|
||||
ingredients.Cell(2, 2).Value = "Rolled oats";
|
||||
ingredients.Cell(2, 3).Value = 80;
|
||||
ingredients.Cell(2, 4).Value = "g";
|
||||
ingredients.Cell(2, 5).Value = 0;
|
||||
ingredients.Cell(3, 1).Value = "Oatmeal with berries";
|
||||
ingredients.Cell(3, 2).Value = "Blueberries";
|
||||
ingredients.Cell(3, 3).Value = 100;
|
||||
ingredients.Cell(3, 4).Value = "g";
|
||||
ingredients.Cell(3, 5).Value = 1;
|
||||
ingredients.Columns().AdjustToContents();
|
||||
|
||||
var steps = workbook.Worksheets.Add("Steps");
|
||||
steps.Cell(1, 1).Value = "RecipeName";
|
||||
steps.Cell(1, 2).Value = "StepNumber";
|
||||
steps.Cell(1, 3).Value = "Description";
|
||||
steps.Row(1).Style.Font.Bold = true;
|
||||
steps.Cell(2, 1).Value = "Oatmeal with berries";
|
||||
steps.Cell(2, 2).Value = 1;
|
||||
steps.Cell(2, 3).Value = "Cook oats in milk for 5 minutes.";
|
||||
steps.Cell(3, 1).Value = "Oatmeal with berries";
|
||||
steps.Cell(3, 2).Value = 2;
|
||||
steps.Cell(3, 3).Value = "Top with berries and serve.";
|
||||
steps.Columns().AdjustToContents();
|
||||
|
||||
var help = workbook.Worksheets.Add("Help");
|
||||
help.Cell(1, 1).Value = "MealCategory values";
|
||||
help.Cell(2, 1).Value = "Breakfast, SecondBreakfast (or Second Breakfast), Lunch, Dinner";
|
||||
help.Cell(2, 2).Value = "Or numeric: 0, 1, 2, 3";
|
||||
help.Cell(4, 1).Value = "RecipeName in Ingredients/Steps must match Name in Recipes sheet exactly.";
|
||||
help.Columns().AdjustToContents();
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public async Task<RecipeImportResultDto> ImportAsync(Stream fileStream, CancellationToken ct = default)
|
||||
{
|
||||
var result = new RecipeImportResultDto();
|
||||
|
||||
using var workbook = new XLWorkbook(fileStream);
|
||||
var recipeSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||||
w.Name.Equals("Recipes", StringComparison.OrdinalIgnoreCase));
|
||||
if (recipeSheet is null)
|
||||
{
|
||||
result.Errors.Add("Worksheet 'Recipes' not found.");
|
||||
return result;
|
||||
}
|
||||
|
||||
var ingredientSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||||
w.Name.Equals("Ingredients", StringComparison.OrdinalIgnoreCase));
|
||||
var stepSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||||
w.Name.Equals("Steps", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var ingredientRows = ParseIngredients(ingredientSheet, result.Errors);
|
||||
var stepRows = ParseSteps(stepSheet, result.Errors);
|
||||
|
||||
var recipeRows = recipeSheet.RowsUsed().Skip(1);
|
||||
foreach (var row in recipeRows)
|
||||
{
|
||||
var name = row.Cell(1).GetString().Trim();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseCategory(row.Cell(2).GetString(), out var category, out var categoryError))
|
||||
{
|
||||
result.Errors.Add($"Recipe '{name}': {categoryError}");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var dto = new CreateRecipeDto
|
||||
{
|
||||
Name = name,
|
||||
MealCategory = category,
|
||||
Calories = ParseNullableInt(row.Cell(3)),
|
||||
Protein = ParseNullableDecimal(row.Cell(4)),
|
||||
Fat = ParseNullableDecimal(row.Cell(5)),
|
||||
Carbs = ParseNullableDecimal(row.Cell(6)),
|
||||
PrepTimeMinutes = ParseNullableInt(row.Cell(7)),
|
||||
Ingredients = ingredientRows
|
||||
.Where(i => i.RecipeName.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(i => new CreateIngredientDto
|
||||
{
|
||||
Name = i.Name,
|
||||
AmountGrams = i.AmountGrams,
|
||||
Unit = i.Unit,
|
||||
SortOrder = i.SortOrder,
|
||||
})
|
||||
.ToList(),
|
||||
Steps = stepRows
|
||||
.Where(s => s.RecipeName.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(s => s.StepNumber)
|
||||
.Select(s => new CreateRecipeStepDto
|
||||
{
|
||||
StepNumber = s.StepNumber,
|
||||
Description = s.Description,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var exists = await _db.Recipes.AnyAsync(
|
||||
r => r.IsActive && r.Name.ToLower() == name.ToLower(), ct);
|
||||
if (exists)
|
||||
{
|
||||
result.Errors.Add($"Recipe '{name}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var entity = RecipeManagementService.MapToEntity(dto);
|
||||
entity.CreatedAt = DateTime.UtcNow;
|
||||
entity.IsActive = true;
|
||||
_db.Recipes.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
result.CreatedCount++;
|
||||
result.CreatedRecipeIds.Add(entity.Id);
|
||||
_logger.LogInformation("Imported recipe {RecipeId} '{Name}' from Excel", entity.Id, name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to import recipe '{Name}'", name);
|
||||
result.Errors.Add($"Recipe '{name}': {ex.Message}");
|
||||
result.SkippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<IngredientRow> ParseIngredients(IXLWorksheet? sheet, List<string> errors)
|
||||
{
|
||||
var rows = new List<IngredientRow>();
|
||||
if (sheet is null)
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
|
||||
foreach (var row in sheet.RowsUsed().Skip(1))
|
||||
{
|
||||
var recipeName = row.Cell(1).GetString().Trim();
|
||||
var name = row.Cell(2).GetString().Trim();
|
||||
if (string.IsNullOrEmpty(recipeName) || string.IsNullOrEmpty(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new IngredientRow(
|
||||
recipeName,
|
||||
name,
|
||||
ParseNullableDecimal(row.Cell(3)),
|
||||
NullIfEmpty(row.Cell(4).GetString()),
|
||||
ParseNullableInt(row.Cell(5)) ?? rows.Count(r =>
|
||||
r.RecipeName.Equals(recipeName, StringComparison.OrdinalIgnoreCase))));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<StepRow> ParseSteps(IXLWorksheet? sheet, List<string> errors)
|
||||
{
|
||||
var rows = new List<StepRow>();
|
||||
if (sheet is null)
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
|
||||
foreach (var row in sheet.RowsUsed().Skip(1))
|
||||
{
|
||||
var recipeName = row.Cell(1).GetString().Trim();
|
||||
var stepNumber = ParseNullableInt(row.Cell(2));
|
||||
var description = row.Cell(3).GetString().Trim();
|
||||
if (string.IsNullOrEmpty(recipeName) || stepNumber is null or < 1 || string.IsNullOrEmpty(description))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new StepRow(recipeName, stepNumber.Value, description));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
internal static bool TryParseCategory(string raw, out int category, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
category = 0;
|
||||
var value = raw.Trim();
|
||||
|
||||
if (int.TryParse(value, out category) && category is >= 0 and <= 3)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
category = value.ToLowerInvariant().Replace(" ", "") switch
|
||||
{
|
||||
"breakfast" => 0,
|
||||
"secondbreakfast" => 1,
|
||||
"lunch" => 2,
|
||||
"dinner" => 3,
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
if (category < 0)
|
||||
{
|
||||
error = $"Invalid MealCategory '{raw}'. Use Breakfast, SecondBreakfast, Lunch, Dinner or 0–3.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int? ParseNullableInt(IXLCell cell)
|
||||
{
|
||||
if (cell.IsEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cell.TryGetValue(out int intValue))
|
||||
{
|
||||
return intValue;
|
||||
}
|
||||
|
||||
return int.TryParse(cell.GetString(), out intValue) ? intValue : null;
|
||||
}
|
||||
|
||||
private static decimal? ParseNullableDecimal(IXLCell cell)
|
||||
{
|
||||
if (cell.IsEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cell.TryGetValue(out double doubleValue))
|
||||
{
|
||||
return (decimal)doubleValue;
|
||||
}
|
||||
|
||||
return decimal.TryParse(cell.GetString(), out var decimalValue) ? decimalValue : null;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed record IngredientRow(string RecipeName, string Name, decimal? AmountGrams, string? Unit, int SortOrder);
|
||||
|
||||
private sealed record StepRow(string RecipeName, int StepNumber, string Description);
|
||||
}
|
||||
Reference in New Issue
Block a user