* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View 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 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);
}