Files
DailyMeals/MealPlan.Api/Services/RecipeExcelService.cs
Piotr Kus f15bb7a916 * Extended functionalities
* Added different UIs
2026-06-24 21:43:40 +02:00

307 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 03.";
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);
}