From 41bd8254e7495433b12e3468f1fa309bfaa99c35 Mon Sep 17 00:00:00 2001 From: Byakuya Date: Tue, 16 Sep 2025 22:20:21 +0200 Subject: [PATCH] Correction PDF --- .config/dotnet-tools.json | 13 + Controllers/ConnectionsController.cs | 10 + .../HelloFresh/HelloFreshController.cs | 155 +++++++++- Models/Finances/FinancesContext.cs | 4 - Models/HelloFresh/HelloFreshContext.cs | 12 +- Models/HelloFresh/HistoriqueRecette.cs | 2 +- Models/HelloFresh/Ingredient.cs | 2 + Models/LayoutDataContext.cs | 3 - Program.cs | 35 ++- Views/HelloFresh/Ingredients.cshtml | 11 + administration.csproj | 11 +- appsettings.Development.json | 17 +- appsettings.json | 2 +- wwwroot/js/HelloFresh/cuisine.js | 7 - wwwroot/js/HelloFresh/index.js | 112 +++++-- wwwroot/js/HelloFresh/ingredients.js | 289 +++++++++++++++--- wwwroot/js/site.js | 16 + 17 files changed, 591 insertions(+), 110 deletions(-) create mode 100644 .config/dotnet-tools.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..717b02d --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.20", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/Controllers/ConnectionsController.cs b/Controllers/ConnectionsController.cs index b51a1de..be6e3e2 100644 --- a/Controllers/ConnectionsController.cs +++ b/Controllers/ConnectionsController.cs @@ -104,5 +104,15 @@ namespace administration.Controllers return Content("✅ Mot de passe mis à jour avec succès"); } + + [HttpGet("/tools/hash")] + [Authorize] // et/ou protège par un flag d'env + public IActionResult HashTool(string username, string password, [FromServices] IPasswordHasher hasher) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) return BadRequest("username/password requis"); + var hash = hasher.HashPassword(new User { Username = username }, password); + return Content(hash); + } + } } diff --git a/Controllers/HelloFresh/HelloFreshController.cs b/Controllers/HelloFresh/HelloFreshController.cs index 8165858..d3fe8c5 100644 --- a/Controllers/HelloFresh/HelloFreshController.cs +++ b/Controllers/HelloFresh/HelloFreshController.cs @@ -50,14 +50,20 @@ public class HelloFreshController : AppController { try { - if (page < 1 || count < 1) - return BadRequest("Les paramètres 'page' et 'count' doivent être supérieurs à 0."); + if (page < 1 || count < 1) return BadRequest("Les paramètres 'page' et 'count' doivent être supérieurs à 0."); + + // ✅ Base query : uniquement les recettes avec un PDF non vide + var q = _context.Recettes + .AsNoTracking() + .Where(r => r.Pdf != null && r.Pdf != "") // éviter IsNullOrWhiteSpace pour une traduction SQL sûre + .AsQueryable(); - var q = _context.Recettes.AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) { - var s = search.Trim().ToLowerInvariant(); - q = q.Where(r => r.Name.ToLower().Contains(s)); + var s = search.Trim(); + q = q.Where(r => EF.Functions.Like(r.Name, $"%{s}%")); + // Optionnel : chercher aussi dans les tags + // q = q.Where(r => EF.Functions.Like(r.Name, $"%{s}%") || (r.Tags != null && EF.Functions.Like(r.Tags, $"%{s}%"))); } var totalItems = q.Count(); @@ -84,6 +90,8 @@ public class HelloFreshController : AppController } } + + // ❌ Supprime la limite de 3 portions [HttpPost("SaveRecipe")] public IActionResult SaveRecipe([FromBody] string idSavingRecette) @@ -393,12 +401,16 @@ public class HelloFreshController : AppController var userId = HttpContext.Session.GetInt32("UserId"); if (userId == null) return Unauthorized("Utilisateur non connecté."); + var scopeIdsOwned = PantryScopeUserIds(); var ownedNames = _context.Ingredients + .AsNoTracking() + .Where(i => scopeIdsOwned.Contains(i.UserId)) // 👈 union Mae+Byakuya .Select(i => i.NameOwnedIngredients) .ToList() .Select(s => (s ?? "").Trim().ToLowerInvariant()) .ToHashSet(); + var perRecipe = _context.SavingRecettes .GroupBy(s => s.IdSavingRecette) .Select(g => new { RecetteId = g.Key, Portions = g.Count() }) @@ -494,11 +506,16 @@ public class HelloFreshController : AppController var userId = HttpContext.Session.GetInt32("UserId"); if (userId == null) return Unauthorized("Utilisateur non connecté."); - var ownedNames = _context.Ingredients.Select(i => i.NameOwnedIngredients) + var scopeIdsOwned = PantryScopeUserIds(); + var ownedNames = _context.Ingredients + .AsNoTracking() + .Where(i => scopeIdsOwned.Contains(i.UserId)) // 👈 union Mae+Byakuya + .Select(i => i.NameOwnedIngredients) .ToList() .Select(s => (s ?? "").Trim().ToLowerInvariant()) .ToHashSet(); + var perRecipe = _context.SavingRecettes .Where(s => s.UserId == userId) .GroupBy(s => s.IdSavingRecette) @@ -640,7 +657,7 @@ public class HelloFreshController : AppController var idList = ids.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); var recipes = _context.Recettes - .Where(r => idList.Contains(r.Id)) + .Where(r => idList.Contains(r.Id) && !string.IsNullOrEmpty(r.Pdf)) // 👈 filtre: PDF non vide .Select(r => new { id = r.Id, @@ -654,14 +671,19 @@ public class HelloFreshController : AppController return Ok(recipes); } - [HttpPost("ToggleOwnedIngredient")] public IActionResult ToggleOwnedIngredient([FromBody] string ingredientName) { try { + var userId = HttpContext.Session.GetInt32("UserId"); + if (userId == null) return Unauthorized("Utilisateur non connecté."); + + var name = (ingredientName ?? "").Trim(); + if (string.IsNullOrWhiteSpace(name)) return BadRequest("Nom d'ingrédient invalide."); + var existing = _context.Ingredients - .FirstOrDefault(i => i.NameOwnedIngredients == ingredientName); + .FirstOrDefault(i => i.UserId == userId.Value && i.NameOwnedIngredients == name); if (existing != null) { @@ -671,7 +693,11 @@ public class HelloFreshController : AppController } else { - _context.Ingredients.Add(new Ingredient { NameOwnedIngredients = ingredientName }); + _context.Ingredients.Add(new Ingredient + { + UserId = userId.Value, // 👈 lie l’ingrédient au user courant + NameOwnedIngredients = name + }); _context.SaveChanges(); return Ok(new { status = "added" }); } @@ -682,12 +708,18 @@ public class HelloFreshController : AppController } } + [HttpGet("GetOwnedIngredients")] public IActionResult GetOwnedIngredients() { try { + var scopeIds = PantryScopeUserIds(); + if (scopeIds.Count == 0) return Unauthorized("Utilisateur non connecté."); + var names = _context.Ingredients + .AsNoTracking() + .Where(i => scopeIds.Contains(i.UserId)) // 👈 union Mae+Byakuya, sinon user seul .Select(i => i.NameOwnedIngredients) .Distinct() .ToList(); @@ -700,6 +732,7 @@ public class HelloFreshController : AppController } } + [HttpGet("GetAllRecipes")] public IActionResult GetAllRecipes() { @@ -714,8 +747,10 @@ public class HelloFreshController : AppController }) .ToList(); + // 👇 Ne garder que les recettes avec un PDF non vide var recettes = _context.Recettes .AsNoTracking() + .Where(r => r.Pdf != null && r.Pdf != "") // équiv. à !string.IsNullOrEmpty(r.Pdf) .Select(r => new { r.Id, @@ -723,7 +758,8 @@ public class HelloFreshController : AppController r.Image, r.TempsDePreparation, r.Tags, - IngredientsToHad = r.IngredientsToHad + IngredientsToHad = r.IngredientsToHad, + r.Pdf // utilisé pour le filtre (pas renvoyé ensuite) }) .ToList(); @@ -773,6 +809,7 @@ public class HelloFreshController : AppController return Ok(result); } + [HttpGet("/HelloFresh/GetAllRecipesWithUsers")] public IActionResult GetAllRecipesWithUsers() { @@ -1095,4 +1132,100 @@ public class HelloFreshController : AppController return Ok(distinct); } + + // GET /HelloFresh/GetRecipesOwnedByUsers?names=Mae,Byakuya + [HttpGet("GetRecipesOwnedByUsers")] + public IActionResult GetRecipesOwnedByUsers([FromQuery] string names) + { + var raw = (names ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0) + .ToList(); + if (raw.Count == 0) return Ok(new List()); + + // usernames -> ids + var users = _layout.Users.AsNoTracking() + .Where(u => raw.Contains(u.Username)) + .Select(u => new { u.Id, u.Username }) + .ToList(); + + if (users.Count == 0) return Ok(new List()); + + var userIds = users.Select(u => u.Id).ToList(); + var idToName = users.ToDictionary(u => u.Id, u => u.Username); + + // portions par (Recette, User) + var counts = _context.SavingRecettes.AsNoTracking() + .Where(s => userIds.Contains(s.UserId)) + .GroupBy(s => new { s.IdSavingRecette, s.UserId }) + .Select(g => new { RecetteId = g.Key.IdSavingRecette, UserId = g.Key.UserId, Portions = g.Count() }) + .ToList(); + + if (counts.Count == 0) return Ok(new List()); + + var recetteIds = counts.Select(c => c.RecetteId).Distinct().ToList(); + var recs = _context.Recettes.AsNoTracking() + .Where(r => recetteIds.Contains(r.Id)) + .Select(r => new + { + r.Id, + r.Name, + r.Image, + r.TempsDePreparation, + r.Tags, + r.IngredientsToHad + }) + .ToList(); + var recDict = recs.ToDictionary(r => r.Id, r => r, StringComparer.Ordinal); + + // Agrégation par recette limitée aux users demandés + var result = counts + .GroupBy(c => c.RecetteId) + .Select(g => + { + recDict.TryGetValue(g.Key, out var r); + return new + { + id = g.Key, + name = r?.Name ?? g.Key, + image = r?.Image, + tempsDePreparation = r?.TempsDePreparation ?? 0, + portions = g.Sum(x => x.Portions), + tags = r?.Tags, + users = g.Select(x => idToName.TryGetValue(x.UserId, out var un) ? un : null) + .Where(un => !string.IsNullOrWhiteSpace(un)) + .Distinct() + .ToList(), + ingredientsToHad = r?.IngredientsToHad + }; + }) + .OrderBy(x => x.name, StringComparer.Create(new System.Globalization.CultureInfo("fr-FR"), true)) + .ToList(); + + return Ok(result); + } + + // --- Scope "pantry" (ingrédients possédés) --- + // Mae & Byakuya partagent leur garde-manger ; les autres sont en solo. + private List PantryScopeUserIds() + { + var sessionUserId = HttpContext.Session.GetInt32("UserId"); + if (sessionUserId == null) return new List(); + + var me = _layout.Users.AsNoTracking().FirstOrDefault(u => u.Id == sessionUserId.Value); + if (me == null) return new List { sessionUserId.Value }; + + // Liste blanche des prénoms qui partagent entre eux + var core = new[] { "Mae", "Byakuya" }; + var isCore = core.Any(n => string.Equals(n, me.Username, StringComparison.OrdinalIgnoreCase)); + + if (!isCore) return new List { me.Id }; + + // Récupère les IDs de Mae & Byakuya + return _layout.Users.AsNoTracking() + .Where(u => core.Contains(u.Username)) + .Select(u => u.Id) + .ToList(); + } + } diff --git a/Models/Finances/FinancesContext.cs b/Models/Finances/FinancesContext.cs index a796a80..145698c 100644 --- a/Models/Finances/FinancesContext.cs +++ b/Models/Finances/FinancesContext.cs @@ -25,10 +25,6 @@ public partial class FinancesContext : DbContext public virtual DbSet Users { get; set; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) -#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263. - => optionsBuilder.UseSqlServer("Server=217.154.116.43;Database=Finances;User Id=sa;Password=Mf33ksTRLrPKSqQ4cTXitgiSN6BPBt89;TrustServerCertificate=True;"); - protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => diff --git a/Models/HelloFresh/HelloFreshContext.cs b/Models/HelloFresh/HelloFreshContext.cs index aa6e6f7..e8c870d 100644 --- a/Models/HelloFresh/HelloFreshContext.cs +++ b/Models/HelloFresh/HelloFreshContext.cs @@ -23,22 +23,13 @@ public partial class HelloFreshContext : DbContext public virtual DbSet SavingRecettes { get; set; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) -#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263. - => optionsBuilder.UseSqlServer("Server=217.154.116.43;Database=HelloFresh;User Id=sa;Password=Mf33ksTRLrPKSqQ4cTXitgiSN6BPBt89;TrustServerCertificate=True;"); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { entity.Property(e => e.Id).HasColumnName("id"); - // Map DateOnly -> date SQL - entity.Property(e => e.DateHistorique) - .HasConversion( - v => v.ToDateTime(TimeOnly.MinValue), // vers SQL - v => DateOnly.FromDateTime(v) // depuis SQL - ) - .HasColumnType("date"); + entity.Property(e => e.DateHistorique).HasColumnName("dateHistorique"); entity.Property(e => e.RecetteId).HasColumnName("recetteId"); entity.Property(e => e.UserId).HasColumnName("userId"); }); @@ -47,6 +38,7 @@ public partial class HelloFreshContext : DbContext { entity.Property(e => e.Id).HasColumnName("id"); entity.Property(e => e.NameOwnedIngredients).HasColumnName("nameOwnedIngredients"); + entity.Property(e => e.UserId).HasColumnName("userId"); }); modelBuilder.Entity(entity => diff --git a/Models/HelloFresh/HistoriqueRecette.cs b/Models/HelloFresh/HistoriqueRecette.cs index eec2206..29b8750 100644 --- a/Models/HelloFresh/HistoriqueRecette.cs +++ b/Models/HelloFresh/HistoriqueRecette.cs @@ -7,7 +7,7 @@ public partial class HistoriqueRecette { public int Id { get; set; } - public string RecetteId { get; set; } = ""; // <-- string pour matcher Recette.Id + public string RecetteId { get; set; } = null!; public int UserId { get; set; } diff --git a/Models/HelloFresh/Ingredient.cs b/Models/HelloFresh/Ingredient.cs index 0b86cc2..03db050 100644 --- a/Models/HelloFresh/Ingredient.cs +++ b/Models/HelloFresh/Ingredient.cs @@ -8,4 +8,6 @@ public partial class Ingredient public int Id { get; set; } public string NameOwnedIngredients { get; set; } = null!; + + public int UserId { get; set; } } diff --git a/Models/LayoutDataContext.cs b/Models/LayoutDataContext.cs index e179891..caccca4 100644 --- a/Models/LayoutDataContext.cs +++ b/Models/LayoutDataContext.cs @@ -17,9 +17,6 @@ public partial class LayoutDataContext : DbContext public virtual DbSet Users { get; set; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) -#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see https://go.microsoft.com/fwlink/?LinkId=723263. - => optionsBuilder.UseSqlServer("Server=217.154.116.43,1433;Database=LayoutData;User Id=sa;Password=Mf33ksTRLrPKSqQ4cTXitgiSN6BPBt89;TrustServerCertificate=True;"); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Program.cs b/Program.cs index 395287a..a4371ac 100644 --- a/Program.cs +++ b/Program.cs @@ -26,14 +26,31 @@ namespace administration if (string.IsNullOrEmpty(dbConnection)) throw new Exception("❌ ADMIN_DB_CONNECTION est introuvable."); - builder.Services.AddDbContext(options => - options.UseSqlServer(dbConnection, sqlOptions => sqlOptions.EnableRetryOnFailure())); + bool inContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; + string hostOverride = builder.Configuration["DB_HOST"] ?? Environment.GetEnvironmentVariable("DB_HOST"); - builder.Services.AddDbContext(options => - options.UseSqlServer(builder.Configuration.GetConnectionString("HelloFreshConnection"))); + string Fix(string? cs, string? dbNameFallback) + { + if (string.IsNullOrWhiteSpace(cs) && !string.IsNullOrWhiteSpace(dbNameFallback)) + { + var def = builder.Configuration.GetConnectionString("DefaultConnection") ?? ""; + if (!string.IsNullOrWhiteSpace(def)) + cs = System.Text.RegularExpressions.Regex.Replace(def, @"Database=([^;]+)", $"Database={dbNameFallback}"); + } + if (!string.IsNullOrWhiteSpace(cs) && !inContainer && !string.IsNullOrWhiteSpace(hostOverride)) + { + cs = cs.Replace("Server=sqlserver,1433", $"Server={hostOverride},1433", StringComparison.OrdinalIgnoreCase); + } + return cs ?? ""; + } - builder.Services.AddDbContext(options => - options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); + var csLayout = Fix(builder.Configuration.GetConnectionString("DefaultConnection"), "LayoutData"); + var csHello = Fix(builder.Configuration.GetConnectionString("HelloFresh"), "HelloFresh"); + var csFinance = Fix(builder.Configuration.GetConnectionString("Finances"), "Finances"); + + builder.Services.AddDbContext(o => o.UseSqlServer(csLayout)); + builder.Services.AddDbContext(o => o.UseSqlServer(csHello)); + builder.Services.AddDbContext(o => o.UseSqlServer(csFinance)); AppSettings.Initialize(builder.Configuration); builder.Services.AddSingleton(); @@ -118,6 +135,12 @@ var ollamaBuilder = builder.Services.AddHttpClient("ollama", (sp, client) => var app = builder.Build(); // ============================================== // 6️⃣ Pipeline + app.MapGet("/_dbping", async (HelloFreshContext hf, LayoutDataContext ld) => + { + try { await hf.Database.ExecuteSqlRawAsync("SELECT 1"); } catch (Exception ex) { return Results.Problem($"HelloFresh: {ex.GetBaseException().Message}"); } + try { await ld.Database.ExecuteSqlRawAsync("SELECT 1"); } catch (Exception ex) { return Results.Problem($"LayoutData: {ex.GetBaseException().Message}"); } + return Results.Ok("OK"); + }); if (!app.Environment.IsDevelopment()) { diff --git a/Views/HelloFresh/Ingredients.cshtml b/Views/HelloFresh/Ingredients.cshtml index 9c12689..24e28f8 100644 --- a/Views/HelloFresh/Ingredients.cshtml +++ b/Views/HelloFresh/Ingredients.cshtml @@ -39,4 +39,15 @@
    +@{ + var currentUser = Context.Session.GetString("UserName") ?? ""; + bool isCore = currentUser.Equals("Mae", StringComparison.OrdinalIgnoreCase) + || currentUser.Equals("Byakuya", StringComparison.OrdinalIgnoreCase); +} + diff --git a/administration.csproj b/administration.csproj index 7ccfcff..81dfa4e 100644 --- a/administration.csproj +++ b/administration.csproj @@ -28,15 +28,20 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - + diff --git a/appsettings.Development.json b/appsettings.Development.json index e3c44b0..535c868 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -1,13 +1,16 @@ { - "Ollama": { - "Url": "https://ollama.byakurepo.online", - "Model": "qwen2.5:1.5b-instruct" // plus rapide que 3b - }, "Logging": { "LogLevel": { "Default": "Information", - "Administration.Controllers.SearchController": "Debug", - "administration.Controllers.HelloFreshController": "Debug" + "Microsoft.AspNetCore": "Warning" } - } + }, + + "ConnectionStrings": { + "DefaultConnection": "Server=217.154.116.43,1433;Database=LayoutData;User Id=admin_app;Password=A!pP_2025#V9yTb3Q;Encrypt=True;TrustServerCertificate=True;MultipleActiveResultSets=True;", + "HelloFresh": "Server=217.154.116.43,1433;Database=HelloFresh;User Id=admin_app;Password=A!pP_2025#V9yTb3Q;Encrypt=True;TrustServerCertificate=True;MultipleActiveResultSets=True;", + "Finances": "Server=217.154.116.43,1433;Database=Finances;User Id=admin_app;Password=A!pP_2025#V9yTb3Q;Encrypt=True;TrustServerCertificate=True;MultipleActiveResultSets=True;" + }, + + "AllowedHosts": "*" } diff --git a/appsettings.json b/appsettings.json index 9efe776..aabb649 100644 --- a/appsettings.json +++ b/appsettings.json @@ -4,6 +4,6 @@ "Model": "qwen2.5:3b-instruct" }, "ConnectionStrings": { - "HelloFreshConnection": "Server=217.154.116.43;Database=HelloFresh;User Id=sa;Password=Wi0a6kxXjAhtSswP22FYZW9UBxRrgght8MMncp8F;TrustServerCertificate=True;Encrypt=False" + "HelloFreshConnection": "Server=217.154.116.43;Database=HelloFresh;User Id=admin_app;Password=A!pP_2025#V9yTb3Q;TrustServerCertificate=True;Encrypt=False" } } diff --git a/wwwroot/js/HelloFresh/cuisine.js b/wwwroot/js/HelloFresh/cuisine.js index 70c72ec..7d1b363 100644 --- a/wwwroot/js/HelloFresh/cuisine.js +++ b/wwwroot/js/HelloFresh/cuisine.js @@ -33,13 +33,6 @@ const normalize = (s) => (s ?? "").toString().trim().toLowerCase(); const proxify = (url) => url ? `/HelloFresh/ProxyPdf?url=${encodeURIComponent(url)}` : ""; - // Détection iOS (iPhone/iPad et iPadOS mode desktop) - const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.userAgent.includes("Mac") && "ontouchend" in document); - els.btnOpen?.addEventListener("click", () => { - const r = RECIPES[CUR]; if (!r?.pdf) return; - const u = `/HelloFresh/ProxyPdf?url=${encodeURIComponent(r.pdf)}`; - if (IS_IOS) window.open(u, "_blank", "noopener"); else openViewer(); - }); diff --git a/wwwroot/js/HelloFresh/index.js b/wwwroot/js/HelloFresh/index.js index b3cfcf8..3be1d84 100644 --- a/wwwroot/js/HelloFresh/index.js +++ b/wwwroot/js/HelloFresh/index.js @@ -37,6 +37,19 @@ const splitTags = (str) => (str || '').split(/[,•]/).map(t => t.trim()).filter const esc = (s) => { const d = document.createElement('div'); d.textContent = (s ?? ''); return d.innerHTML; }; function capFirst(s) { if (s == null) return ''; s = s.toString().trim(); if (!s) return ''; const f = s[0].toLocaleUpperCase('fr-FR'); return f + s.slice(1); } +const DEBOUNCE_MS = 250; +function debounce(fn, wait = DEBOUNCE_MS) { + let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; +} +function onlySearchActive() { + return normalize(currentSearchTerm) !== '' && + appliedFilters.ingredients.length === 0 && + appliedFilters.tags.length === 0 && + appliedFilters.timeMin == null && + appliedFilters.timeMax == null; +} + + /*********************** * COLORS for tags (persistées localStorage) ***********************/ @@ -326,28 +339,44 @@ function buildRecipeCardOwned(recipe) { async function loadRecipes(page = 1) { try { const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin); - url.searchParams.set('page', page); url.searchParams.set('count', countPerPage); - const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); + url.searchParams.set('page', page); + url.searchParams.set('count', countPerPage); + + // 👉 On laisse le serveur faire la recherche quand il n'y a QUE la recherche active + if (onlySearchActive()) { + url.searchParams.set('search', currentSearchTerm || ''); + } + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); + const { recipes, currentPage: serverPage, lastPage } = data; currentPage = serverPage; lastPageGlobal = lastPage; + const grid = document.getElementById('recipeGrid'); grid.innerHTML = ''; if (!recipes || recipes.length === 0) { grid.innerHTML = '

    Aucune recette disponible pour le moment.

    '; - renderPagination(currentPage, lastPageGlobal); return; + renderPagination(currentPage, lastPageGlobal); + return; } + recipes.forEach(r => grid.appendChild(buildRecipeCardList(r))); if (window.tippy) { tippy('[data-tippy-content]', { placement: 'top', arrow: true, delay: [100, 0], theme: 'light-border', maxWidth: 250, allowHTML: true }); } await hydrateIngredientsForPage(recipes); refreshSelectedBorders(); - if ((currentSearchTerm ?? '').trim() !== '') applySearchFilter(); + + // ⚠️ si tu avais une ancienne fonction applySearchFilter(), on ne l'appelle plus ici. + // if ((currentSearchTerm ?? '').trim() !== '' && typeof applySearchFilter === 'function') applySearchFilter(); + refreshDoneFlagsOnVisibleCards(); renderPagination(currentPage, lastPageGlobal); } catch (e) { console.error('loadRecipes error', e); } } + async function loadRecipesOwned({ onEmpty = 'placeholder' } = {}) { const gridOwned = document.getElementById('recipeGridOwned'); if (!gridOwned) return false; const wasOpen = document.getElementById('recipeOwnedWrap')?.classList.contains('open'); @@ -450,19 +479,40 @@ function anyFilterActive() { appliedFilters.timeMin != null || appliedFilters.timeMax != null; } -async function fetchAllRecipes() { - if (ALL_RECIPES_CACHE) return ALL_RECIPES_CACHE; + +async function fetchAllRecipes(opts = {}) { + const search = (opts.search || '').trim(); + if (!search && ALL_RECIPES_CACHE) return ALL_RECIPES_CACHE; + let page = 1, last = 1, all = []; do { const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin); - url.searchParams.set('page', page); url.searchParams.set('count', countPerPage); - const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); + url.searchParams.set('page', page); + url.searchParams.set('count', countPerPage); + if (search) url.searchParams.set('search', search); + + const res = await fetch(url.toString()); + if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); all.push(...(data.recipes || [])); - last = data.lastPage || 1; page++; + last = data.lastPage || 1; + page++; } while (page <= last); - ALL_RECIPES_CACHE = all; return all; + + if (!search) ALL_RECIPES_CACHE = all; + return all; } + +async function fetchDetailsBatched(ids, batchSize = 150) { + const out = []; + for (let i = 0; i < ids.length; i += batchSize) { + const part = ids.slice(i, i + batchSize); + const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(part.join(','))}`); + if (res.ok) out.push(...(await res.json())); + } + return out; +} + function recipeMatchesFilters(cardMeta) { const term = normalize(currentSearchTerm); if (term && !cardMeta.name.includes(term)) return false; @@ -487,14 +537,16 @@ function extractIngredientNames(ingredientsJson) { } catch { } return [...out]; } + async function buildMetaFor(recipes) { - const ids = recipes.map(r => r.id).join(','); + const ids = recipes.map(r => r.id); const metaById = {}; + try { - const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(ids)}`); - const details = await res.json(); // [{id, ingredientsJson}] + const details = await fetchDetailsBatched(ids, 150); // ← chunks pour éviter les URLs géantes details.forEach(d => { metaById[d.id] = { ing: extractIngredientNames(d.ingredientsJson).map(normalize) }; }); } catch (e) { console.warn('details fail', e); } + recipes.forEach(r => { const tagsVal = (r.tags ?? r.Tags ?? '').trim(); const tags = splitTags(tagsVal).map(normalize); @@ -502,24 +554,45 @@ async function buildMetaFor(recipes) { const name = normalize(r.name || ''); metaById[r.id] = Object.assign({ tags, time, name, ing: [] }, metaById[r.id] || {}); }); + return metaById; } + async function applyAllFilters() { - if (!anyFilterActive()) { + const hasSearch = normalize(currentSearchTerm) !== ''; + const hasOtherFilters = appliedFilters.ingredients.length || appliedFilters.tags.length || + appliedFilters.timeMin != null || appliedFilters.timeMax != null; + + // A) Rien d'actif → liste classique + if (!hasSearch && !hasOtherFilters) { CURRENT_CLIENT_LIST = null; await loadRecipes(1); - updateActiveFilterBadge(); // peut masquer le badge si rien d’actif + updateActiveFilterBadge(); return; } - const all = await fetchAllRecipes(); + + // B) Uniquement la recherche → serveur (rapide, paginé) + if (hasSearch && !hasOtherFilters) { + CURRENT_CLIENT_LIST = null; // on revient au flux serveur paginé + currentPage = 1; + await loadRecipes(1); // loadRecipes enverra ?search=... + updateActiveFilterBadge(); + return; + } + + // C) Recherche + (tags/ingrédients/temps) OU filtres sans recherche + // → on réduit d'abord côté serveur avec ?search=..., puis filtre côté client + const all = await fetchAllRecipes({ search: hasSearch ? currentSearchTerm : '' }); const meta = await buildMetaFor(all); const filtered = all.filter(r => recipeMatchesFilters(meta[r.id] || {})); + CURRENT_CLIENT_LIST = filtered; currentPage = 1; lastPageGlobal = Math.max(1, Math.ceil(filtered.length / countPerPage)); await renderClientPage(); - updateActiveFilterBadge(); // badge = basé sur appliedFilters UNIQUEMENT + updateActiveFilterBadge(); } + async function renderClientPage() { const list = CURRENT_CLIENT_LIST || []; const start = (currentPage - 1) * countPerPage; @@ -809,9 +882,10 @@ document.addEventListener('DOMContentLoaded', async () => { // Live search const searchInput = document.querySelector('#divSearch input'); if (searchInput) { - searchInput.addEventListener('input', async (e) => { + const debounced = debounce(async () => { await applyAllFilters(); }, 250); + searchInput.addEventListener('input', (e) => { currentSearchTerm = e.target.value; - await applyAllFilters(); // search est immédiat + debounced(); }); } diff --git a/wwwroot/js/HelloFresh/ingredients.js b/wwwroot/js/HelloFresh/ingredients.js index 4a74e99..5e2f60a 100644 --- a/wwwroot/js/HelloFresh/ingredients.js +++ b/wwwroot/js/HelloFresh/ingredients.js @@ -12,11 +12,18 @@ const els = { list: null, search: null }; // --- Exclusions par recette --- const RECIPE_ING_CACHE = new Map(); // id -> { '1': [noms], '2': [...], ... } const EXCLUDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés) + +const GET_ALL_RECIPES_URL = "/HelloFresh/GetAllRecipes"; +const GET_MY_OWNED_URL = "/HelloFresh/GetRecipesOwned?isUserImportant=true"; + +const CORE_USERS = Array.isArray(window.CORE_USERS) ? window.CORE_USERS : ["Mae", "Byakuya"]; + // État “Ingrédients à avoir” let LAST_ING_TO_HAD = null; let LAST_PORTIONS = 1; let lastNeededSignature = ""; let prevNotShippedCount = -1; +const IS_CORE_USER = (window.IS_CORE_USER === true || window.IS_CORE_USER === 'true'); const ACCENT_RX = /[\u0300-\u036f]/g; const normalizeName = (s) => (s || "").trim().toLowerCase(); @@ -295,21 +302,40 @@ function reapplyCurrentSearch() { async function loadAndRenderIngredients() { if (!els.list) return; els.list.innerHTML = "Chargement…"; + try { - const res = await fetch("/HelloFresh/AggregatedIngredients"); - if (!res.ok) { const txt = await res.text().catch(() => ""); console.error("[AggregatedIngredients] HTTP", res.status, txt); els.list.innerHTML = `Erreur chargement ingrédients (${res.status})`; return; } - const data = await res.json(); - ALL_AGG_INGS = Array.isArray(data) ? data : []; - OWNED_SET = new Set(ALL_AGG_INGS.filter(x => x?.Name && x.Owned === true).map(x => x.Name.trim().toLowerCase())); + if (IS_CORE_USER) { + // 🔒 Vue partagée: ingrédients = uniquement Mae/Byakuya + const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`; + const res = await fetch(url, { credentials: "same-origin" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const rows = await res.json(); + window.allRecipes = Array.isArray(rows) ? rows : []; + + const items = await buildAggregatedIngredientsFromRows(window.allRecipes); + ALL_AGG_INGS = items; + + const list = sortIngredientsForDisplay(applyExclusionsTo(items)); + els.list.innerHTML = ""; + renderItems(list.length ? list : []); + if (!list.length) els.list.innerHTML = "Aucun ingrédient"; + return; + } + + // Utilisateur "tiers" : ingrédients = SES recettes (déjà en place) + const mineAgg = await buildMyAggregatedIngredients(); + ALL_AGG_INGS = mineAgg; + const list = sortIngredientsForDisplay(applyExclusionsTo(mineAgg)); els.list.innerHTML = ""; - const initialMerged = mergeDuplicates([...ALL_AGG_INGS]); - const initial = sortIngredientsForDisplay(applyExclusionsTo(initialMerged)); - els.list.innerHTML = ""; - renderItems(initial.length ? initial : []); - if (!initial.length) els.list.innerHTML = "Aucun ingrédient"; - } catch (e) { console.error("[AggregatedIngredients] exception", e); els.list.innerHTML = "Erreur chargement ingrédients"; } + renderItems(list.length ? list : []); + if (!list.length) els.list.innerHTML = "Aucun ingrédient"; + } catch (e) { + console.error("[ingredients] loadAndRenderIngredients exception", e); + els.list.innerHTML = "Erreur chargement ingrédients"; + } } + // ========== Not shipped (DOM) + BDD ========== function getNotShippedIngredientsFromDOM(root = document) { const nodes = Array.from(root.querySelectorAll(".ingredient-item-not-shipped")); @@ -536,13 +562,15 @@ function buildRecipeCardList(recipe) { }); - const usersVal = recipe.Users ?? recipe.users ?? recipe.User ?? recipe.user ?? []; - const usersArr = Array.isArray(usersVal) ? usersVal.filter(Boolean) : String(usersVal || "").split(/[,•]/).map(s => s.trim()).filter(Boolean); - if (usersArr.length) { - const img = card.querySelector(".image-container"); - const wrap = document.createElement("div"); wrap.className = "label-container"; - usersArr.forEach(u => { const span = document.createElement("span"); span.className = "label-badge user-badge"; span.textContent = u; wrap.appendChild(span); }); - img?.appendChild(wrap); + if (IS_CORE_USER) { + const usersVal = recipe.Users ?? recipe.users ?? recipe.User ?? recipe.user ?? []; + const usersArr = Array.isArray(usersVal) ? usersVal.filter(Boolean) : String(usersVal || "").split(/[,•]/).map(s => s.trim()).filter(Boolean); + if (usersArr.length) { + const img = card.querySelector(".image-container"); + const wrap = document.createElement("div"); wrap.className = "label-container"; + usersArr.forEach(u => { const span = document.createElement("span"); span.className = "label-badge user-badge"; span.textContent = u; wrap.appendChild(span); }); + img?.appendChild(wrap); + } } // Mémorise la dernière recette pour un fallback, via détection auto @@ -558,42 +586,227 @@ function buildRecipeCardList(recipe) { return card; } -const GET_ALL_RECIPES_URL = "/HelloFresh/GetAllRecipes"; async function loadAllRecipesHorizontal() { const strip = document.getElementById("recipeStrip"); if (!strip) return; - try { - const r = await fetch("/HelloFresh/GetRecipesOwned?isUserImportant=false", { credentials: "same-origin" }); - if (r.ok) { const data = await r.json(); ownedMap.clear(); (data || []).forEach(x => ownedMap.set(String(x.id), clampQty(x.portions || 0))); } - } catch { } + // === Vue "partagée" (Mae/Byakuya) : ne montrer QUE leurs recettes === + if (IS_CORE_USER) { + try { + const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`; + const res = await fetch(url, { credentials: "same-origin" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const onlyCore = await res.json(); + window.allRecipes = Array.isArray(onlyCore) ? onlyCore : []; + strip.innerHTML = ""; + if (!window.allRecipes.length) { + strip.innerHTML = '

    Aucune recette (Mae/Byakuya).

    '; + updateNeededList(); + return; + } + + window.allRecipes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" })); + window.allRecipes.forEach(r => strip.appendChild(buildRecipeCardList(r))); + updateNeededList("afterCoreRecipes"); + } catch (e) { + console.error("loadAllRecipesHorizontal (core) error", e); + strip.innerHTML = '

    Erreur de chargement.

    '; + updateNeededList(); + } + return; + } + + // === Utilisateur "tiers" : uniquement SES recettes === try { - const res = await fetch(GET_ALL_RECIPES_URL, { credentials: "same-origin" }); + const res = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const all = await res.json(); - window.allRecipes = all; + const mine = await res.json(); + window.allRecipes = Array.isArray(mine) ? mine : []; strip.innerHTML = ""; - if (!Array.isArray(all) || all.length === 0) { strip.innerHTML = '

    Aucune recette disponible.

    '; updateNeededList(); return; } - - all.sort((a, b) => { - const ua = String(a.userId ?? ""), ub = String(b.userId ?? ""); - if (ua !== ub) return ua.localeCompare(ub); - return String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" }); - }); - - all.forEach(r => strip.appendChild(buildRecipeCardList(r))); - updateNeededList("afterGetAllRecipes"); - console.debug("[needed] recipes loaded:", Array.isArray(window.allRecipes) ? window.allRecipes.length : 0); + if (!window.allRecipes.length) { + strip.innerHTML = '

    Vous n’avez pas encore de recettes sélectionnées.

    '; + updateNeededList(); + return; + } + window.allRecipes.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""), "fr", { sensitivity: "base" })); + window.allRecipes.forEach(r => strip.appendChild(buildRecipeCardList(r))); + updateNeededList("afterGetMyRecipes"); } catch (e) { - console.error("loadAllRecipesHorizontal error", e); + console.error("loadAllRecipesHorizontal (mine) error", e); strip.innerHTML = '

    Erreur de chargement.

    '; updateNeededList(); } } +async function refreshOwnedSet() { + try { + const r = await fetch("/HelloFresh/GetOwnedIngredients"); + if (r.ok) { + const names = await r.json(); + OWNED_SET = new Set((names || []).map(normalizeName)); + } + } catch { /* ignore */ } +} + +async function buildAggregatedIngredientsFromRows(rows) { + const ids = (rows || []).map(r => r.id); + const details = await fetchDetailsBatched(ids); + const byId = new Map(details.map(d => [String(d.id), d])); + await refreshOwnedSet(); + + const rawItems = []; + for (const r of (rows || [])) { + const det = byId.get(String(r.id)); + if (!det || !det.ingredientsJson) continue; + + let portions = Number(r.portions || 1); + if (!Number.isFinite(portions) || portions < 1) portions = 1; + + const base = Math.min(4, Math.max(1, portions)); + const scale = (portions <= 4) ? 1 : (portions / 4); + + let obj; try { obj = JSON.parse(det.ingredientsJson); } catch { obj = null; } + const arr = (obj && Array.isArray(obj[String(base)])) ? obj[String(base)] : []; + for (const el of arr) { + const it = normalizeItem(el); + if (!it.Name) continue; + + const { value, unit } = parseQuantity(it.Quantity); + if (!Number.isNaN(value) && scale !== 1) { + const scaled = value * scale; + it.Quantity = unit ? `${fmtNumber(scaled)} ${unit}` : fmtNumber(scaled); + } + it.Owned = isOwned(it.Name); + rawItems.push(it); + } + } + return aggregateRawItems(rawItems); +} + + +function fmtNumber(n) { + const i = Math.round(n); + return (Math.abs(n - i) < 1e-9) ? String(i) : String(Number(n.toFixed(2))).replace(/\.?0+$/, ''); +} + +function aggregateRawItems(rawItems) { + // Somme numérique par (name, unit) + “meilleure” quantité textuelle sinon + const numMap = new Map(); // key: name##unit -> {sum, unit, item} + const textMap = new Map(); // key: name -> {set:Set(qty), item} + + for (const raw of rawItems) { + const it = normalizeItem(raw); + if (!it.Name) continue; + const keyName = normalizeName(it.Name); + const q = it.Quantity || ""; + const { value, unit } = parseQuantity(q); + + if (!Number.isNaN(value)) { + const k = `${keyName}##${unit || ""}`; + const prev = numMap.get(k); + if (!prev) { + numMap.set(k, { sum: value, unit: unit || "", item: { ...it } }); + } else { + prev.sum += value; + // garder une image si absente + if (!prev.item.Image && it.Image) prev.item.Image = it.Image; + prev.item.Owned = prev.item.Owned || it.Owned; + } + } else { + const p = textMap.get(keyName); + if (!p) { + const s = new Set(); if (q) s.add(q); + textMap.set(keyName, { set: s, item: { ...it } }); + } else { + if (q) p.set.add(q); + if (!p.item.Image && it.Image) p.item.Image = it.Image; + p.item.Owned = p.item.Owned || it.Owned; + } + } + } + + const out = []; + + // numérique → format “val unit” + for (const { sum, unit, item } of numMap.values()) { + const qty = unit ? `${fmtNumber(sum)} ${unit}` : fmtNumber(sum); + out.push({ ...item, Quantity: qty }); + } + + // textuel → pickBestQuantity + for (const { set, item } of textMap.values()) { + const qty = pickBestQuantity(Array.from(set)); + out.push({ ...item, Quantity: qty }); + } + + return out; +} + +async function fetchDetailsBatched(ids, batchSize = 120) { + const all = []; + for (let i = 0; i < ids.length; i += batchSize) { + const part = ids.slice(i, i + batchSize); + const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(part.join(','))}`); + if (res.ok) all.push(...await res.json()); + } + return all; +} + +async function buildMyAggregatedIngredients() { + // 1) mes recettes + portions + const r = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const mine = await r.json(); // [{id, portions, ...}] + + if (!Array.isArray(mine) || mine.length === 0) return []; + + // 2) détails (ingrédients par 1..4) + const ids = mine.map(x => x.id); + const details = await fetchDetailsBatched(ids); + const byId = new Map(details.map(d => [String(d.id), d])); + + // 3) Owned flags (facultatif, pour tri visuel) + await refreshOwnedSet(); + + // 4) construire la liste brute + mise à l’échelle des quantités (>4 portions) + const rawItems = []; + for (const rcp of mine) { + const id = String(rcp.id); + const det = byId.get(id); + if (!det || !det.ingredientsJson) continue; + + let portions = Number(rcp.portions || 1); + if (!Number.isFinite(portions) || portions < 1) portions = 1; + + const base = Math.min(4, Math.max(1, portions)); + const scale = (portions <= 4) ? 1 : (portions / 4); + + let obj; + try { obj = JSON.parse(det.ingredientsJson); } catch { obj = null; } + const arr = (obj && Array.isArray(obj[String(base)])) ? obj[String(base)] : []; + for (const el of arr) { + const it = normalizeItem(el); + if (!it.Name) continue; + + // scale quantité si numérique + const { value, unit } = parseQuantity(it.Quantity); + if (!Number.isNaN(value) && scale !== 1) { + const scaled = value * scale; + it.Quantity = unit ? `${fmtNumber(scaled)} ${unit}` : fmtNumber(scaled); + } + + it.Owned = isOwned(it.Name); + rawItems.push(it); + } + } + + return aggregateRawItems(rawItems); +} + + // ====================== // Init // ====================== diff --git a/wwwroot/js/site.js b/wwwroot/js/site.js index a95d3c0..e70a2ce 100644 --- a/wwwroot/js/site.js +++ b/wwwroot/js/site.js @@ -55,6 +55,22 @@ })(jQuery); // End of use strict +async function openRecipePdfById(id, initialUrl) { + let pdfUrl = initialUrl || ''; + if (!pdfUrl) { + try { + const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(id)}`); + if (res.ok) { + const arr = await res.json(); + pdfUrl = (arr?.[0]?.pdf || arr?.[0]?.Pdf || ''); + } + } catch { /* ignore */ } + } + if (!pdfUrl) { alert("Aucun PDF pour cette recette."); return; } + const proxied = `/HelloFresh/ProxyPdf?url=${encodeURIComponent(pdfUrl)}`; + showPdfModal(proxied); +} + /** * 💶 Formate un nombre en chaîne de caractères au format français avec division par 1000. * Exemples :