using ClosedXML.Excel; using MealPlan.Api.Data; using MealPlan.Api.DTOs.Recipe; using MealPlan.Api.Models; using Microsoft.EntityFrameworkCore; namespace MealPlan.Api.Services; /// /// Imports recipes from a three-sheet Excel workbook (Recipes, Ingredients, Steps). /// Also generates a downloadable template in the same format. /// public class RecipeExcelService : IRecipeExcelService { private readonly AppDbContext _db; private readonly ILogger _logger; public RecipeExcelService(AppDbContext db, ILogger 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 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 ParseIngredients(IXLWorksheet? sheet, List errors) { var rows = new List(); 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 ParseSteps(IXLWorksheet? sheet, List errors) { var rows = new List(); 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); }