Correction PDF

This commit is contained in:
2025-09-16 22:20:21 +02:00
parent d9fca86145
commit 41bd8254e7
17 changed files with 591 additions and 110 deletions

13
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.20",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -104,5 +104,15 @@ namespace administration.Controllers
return Content("✅ Mot de passe mis à jour avec succès"); 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<User> 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);
}
} }
} }

View File

@@ -50,14 +50,20 @@ public class HelloFreshController : AppController
{ {
try try
{ {
if (page < 1 || count < 1) if (page < 1 || count < 1) return BadRequest("Les paramètres 'page' et 'count' doivent être supérieurs à 0.");
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)) if (!string.IsNullOrWhiteSpace(search))
{ {
var s = search.Trim().ToLowerInvariant(); var s = search.Trim();
q = q.Where(r => r.Name.ToLower().Contains(s)); 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(); var totalItems = q.Count();
@@ -84,6 +90,8 @@ public class HelloFreshController : AppController
} }
} }
// ❌ Supprime la limite de 3 portions // ❌ Supprime la limite de 3 portions
[HttpPost("SaveRecipe")] [HttpPost("SaveRecipe")]
public IActionResult SaveRecipe([FromBody] string idSavingRecette) public IActionResult SaveRecipe([FromBody] string idSavingRecette)
@@ -393,12 +401,16 @@ public class HelloFreshController : AppController
var userId = HttpContext.Session.GetInt32("UserId"); var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté."); if (userId == null) return Unauthorized("Utilisateur non connecté.");
var scopeIdsOwned = PantryScopeUserIds();
var ownedNames = _context.Ingredients var ownedNames = _context.Ingredients
.AsNoTracking()
.Where(i => scopeIdsOwned.Contains(i.UserId)) // 👈 union Mae+Byakuya
.Select(i => i.NameOwnedIngredients) .Select(i => i.NameOwnedIngredients)
.ToList() .ToList()
.Select(s => (s ?? "").Trim().ToLowerInvariant()) .Select(s => (s ?? "").Trim().ToLowerInvariant())
.ToHashSet(); .ToHashSet();
var perRecipe = _context.SavingRecettes var perRecipe = _context.SavingRecettes
.GroupBy(s => s.IdSavingRecette) .GroupBy(s => s.IdSavingRecette)
.Select(g => new { RecetteId = g.Key, Portions = g.Count() }) .Select(g => new { RecetteId = g.Key, Portions = g.Count() })
@@ -494,11 +506,16 @@ public class HelloFreshController : AppController
var userId = HttpContext.Session.GetInt32("UserId"); var userId = HttpContext.Session.GetInt32("UserId");
if (userId == null) return Unauthorized("Utilisateur non connecté."); 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() .ToList()
.Select(s => (s ?? "").Trim().ToLowerInvariant()) .Select(s => (s ?? "").Trim().ToLowerInvariant())
.ToHashSet(); .ToHashSet();
var perRecipe = _context.SavingRecettes var perRecipe = _context.SavingRecettes
.Where(s => s.UserId == userId) .Where(s => s.UserId == userId)
.GroupBy(s => s.IdSavingRecette) .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 idList = ids.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList();
var recipes = _context.Recettes 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 .Select(r => new
{ {
id = r.Id, id = r.Id,
@@ -654,14 +671,19 @@ public class HelloFreshController : AppController
return Ok(recipes); return Ok(recipes);
} }
[HttpPost("ToggleOwnedIngredient")] [HttpPost("ToggleOwnedIngredient")]
public IActionResult ToggleOwnedIngredient([FromBody] string ingredientName) public IActionResult ToggleOwnedIngredient([FromBody] string ingredientName)
{ {
try 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 var existing = _context.Ingredients
.FirstOrDefault(i => i.NameOwnedIngredients == ingredientName); .FirstOrDefault(i => i.UserId == userId.Value && i.NameOwnedIngredients == name);
if (existing != null) if (existing != null)
{ {
@@ -671,7 +693,11 @@ public class HelloFreshController : AppController
} }
else else
{ {
_context.Ingredients.Add(new Ingredient { NameOwnedIngredients = ingredientName }); _context.Ingredients.Add(new Ingredient
{
UserId = userId.Value, // 👈 lie lingrédient au user courant
NameOwnedIngredients = name
});
_context.SaveChanges(); _context.SaveChanges();
return Ok(new { status = "added" }); return Ok(new { status = "added" });
} }
@@ -682,12 +708,18 @@ public class HelloFreshController : AppController
} }
} }
[HttpGet("GetOwnedIngredients")] [HttpGet("GetOwnedIngredients")]
public IActionResult GetOwnedIngredients() public IActionResult GetOwnedIngredients()
{ {
try try
{ {
var scopeIds = PantryScopeUserIds();
if (scopeIds.Count == 0) return Unauthorized("Utilisateur non connecté.");
var names = _context.Ingredients var names = _context.Ingredients
.AsNoTracking()
.Where(i => scopeIds.Contains(i.UserId)) // 👈 union Mae+Byakuya, sinon user seul
.Select(i => i.NameOwnedIngredients) .Select(i => i.NameOwnedIngredients)
.Distinct() .Distinct()
.ToList(); .ToList();
@@ -700,6 +732,7 @@ public class HelloFreshController : AppController
} }
} }
[HttpGet("GetAllRecipes")] [HttpGet("GetAllRecipes")]
public IActionResult GetAllRecipes() public IActionResult GetAllRecipes()
{ {
@@ -714,8 +747,10 @@ public class HelloFreshController : AppController
}) })
.ToList(); .ToList();
// 👇 Ne garder que les recettes avec un PDF non vide
var recettes = _context.Recettes var recettes = _context.Recettes
.AsNoTracking() .AsNoTracking()
.Where(r => r.Pdf != null && r.Pdf != "") // équiv. à !string.IsNullOrEmpty(r.Pdf)
.Select(r => new .Select(r => new
{ {
r.Id, r.Id,
@@ -723,7 +758,8 @@ public class HelloFreshController : AppController
r.Image, r.Image,
r.TempsDePreparation, r.TempsDePreparation,
r.Tags, r.Tags,
IngredientsToHad = r.IngredientsToHad IngredientsToHad = r.IngredientsToHad,
r.Pdf // utilisé pour le filtre (pas renvoyé ensuite)
}) })
.ToList(); .ToList();
@@ -773,6 +809,7 @@ public class HelloFreshController : AppController
return Ok(result); return Ok(result);
} }
[HttpGet("/HelloFresh/GetAllRecipesWithUsers")] [HttpGet("/HelloFresh/GetAllRecipesWithUsers")]
public IActionResult GetAllRecipesWithUsers() public IActionResult GetAllRecipesWithUsers()
{ {
@@ -1095,4 +1132,100 @@ public class HelloFreshController : AppController
return Ok(distinct); 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<object>());
// 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<object>());
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<object>());
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<int> PantryScopeUserIds()
{
var sessionUserId = HttpContext.Session.GetInt32("UserId");
if (sessionUserId == null) return new List<int>();
var me = _layout.Users.AsNoTracking().FirstOrDefault(u => u.Id == sessionUserId.Value);
if (me == null) return new List<int> { 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<int> { 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();
}
} }

View File

@@ -25,10 +25,6 @@ public partial class FinancesContext : DbContext
public virtual DbSet<User> Users { get; set; } public virtual DbSet<User> 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<AdditionalSource>(entity => modelBuilder.Entity<AdditionalSource>(entity =>

View File

@@ -23,22 +23,13 @@ public partial class HelloFreshContext : DbContext
public virtual DbSet<SavingRecette> SavingRecettes { get; set; } public virtual DbSet<SavingRecette> 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<HistoriqueRecette>(entity => modelBuilder.Entity<HistoriqueRecette>(entity =>
{ {
entity.Property(e => e.Id).HasColumnName("id"); entity.Property(e => e.Id).HasColumnName("id");
// Map DateOnly -> date SQL entity.Property(e => e.DateHistorique).HasColumnName("dateHistorique");
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.RecetteId).HasColumnName("recetteId"); entity.Property(e => e.RecetteId).HasColumnName("recetteId");
entity.Property(e => e.UserId).HasColumnName("userId"); 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.Id).HasColumnName("id");
entity.Property(e => e.NameOwnedIngredients).HasColumnName("nameOwnedIngredients"); entity.Property(e => e.NameOwnedIngredients).HasColumnName("nameOwnedIngredients");
entity.Property(e => e.UserId).HasColumnName("userId");
}); });
modelBuilder.Entity<Recette>(entity => modelBuilder.Entity<Recette>(entity =>

View File

@@ -7,7 +7,7 @@ public partial class HistoriqueRecette
{ {
public int Id { get; set; } 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; } public int UserId { get; set; }

View File

@@ -8,4 +8,6 @@ public partial class Ingredient
public int Id { get; set; } public int Id { get; set; }
public string NameOwnedIngredients { get; set; } = null!; public string NameOwnedIngredients { get; set; } = null!;
public int UserId { get; set; }
} }

View File

@@ -17,9 +17,6 @@ public partial class LayoutDataContext : DbContext
public virtual DbSet<User> Users { get; set; } public virtual DbSet<User> 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {

View File

@@ -26,14 +26,31 @@ namespace administration
if (string.IsNullOrEmpty(dbConnection)) if (string.IsNullOrEmpty(dbConnection))
throw new Exception("❌ ADMIN_DB_CONNECTION est introuvable."); throw new Exception("❌ ADMIN_DB_CONNECTION est introuvable.");
builder.Services.AddDbContext<FinancesContext>(options => bool inContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true";
options.UseSqlServer(dbConnection, sqlOptions => sqlOptions.EnableRetryOnFailure())); string hostOverride = builder.Configuration["DB_HOST"] ?? Environment.GetEnvironmentVariable("DB_HOST");
builder.Services.AddDbContext<HelloFreshContext>(options => string Fix(string? cs, string? dbNameFallback)
options.UseSqlServer(builder.Configuration.GetConnectionString("HelloFreshConnection"))); {
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<LayoutDataContext>(options => var csLayout = Fix(builder.Configuration.GetConnectionString("DefaultConnection"), "LayoutData");
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); var csHello = Fix(builder.Configuration.GetConnectionString("HelloFresh"), "HelloFresh");
var csFinance = Fix(builder.Configuration.GetConnectionString("Finances"), "Finances");
builder.Services.AddDbContext<LayoutDataContext>(o => o.UseSqlServer(csLayout));
builder.Services.AddDbContext<HelloFreshContext>(o => o.UseSqlServer(csHello));
builder.Services.AddDbContext<FinancesContext>(o => o.UseSqlServer(csFinance));
AppSettings.Initialize(builder.Configuration); AppSettings.Initialize(builder.Configuration);
builder.Services.AddSingleton<OllamaService>(); builder.Services.AddSingleton<OllamaService>();
@@ -118,6 +135,12 @@ var ollamaBuilder = builder.Services.AddHttpClient("ollama", (sp, client) =>
var app = builder.Build(); var app = builder.Build();
// ============================================== // ==============================================
// 6⃣ Pipeline // 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()) if (!app.Environment.IsDevelopment())
{ {

View File

@@ -39,4 +39,15 @@
<ul id="neededIngredients" class="hf-grid"></ul> <ul id="neededIngredients" class="hf-grid"></ul>
</section> </section>
@{
var currentUser = Context.Session.GetString("UserName") ?? "";
bool isCore = currentUser.Equals("Mae", StringComparison.OrdinalIgnoreCase)
|| currentUser.Equals("Byakuya", StringComparison.OrdinalIgnoreCase);
}
<script>
window.CURRENT_USER = @Html.Raw(System.Text.Json.JsonSerializer.Serialize(currentUser));
window.IS_CORE_USER = @Html.Raw(isCore.ToString().ToLower());
// 👉 liste blanche pour la vue "partagée"
window.CORE_USERS = ["Mae","Byakuya"];
</script>
<script src="~/js/HelloFresh/ingredients.js" asp-append-version="true"></script> <script src="~/js/HelloFresh/ingredients.js" asp-append-version="true"></script>

View File

@@ -28,15 +28,20 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" /> <PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="DotNetEnv" Version="3.1.1" /> <PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.20">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.20">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.SqlServer.Server" Version="1.0.0" /> <PackageReference Include="Microsoft.SqlServer.Server" Version="1.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.*" />
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" /> <PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
</ItemGroup> </ItemGroup>

View File

@@ -1,13 +1,16 @@
{ {
"Ollama": {
"Url": "https://ollama.byakurepo.online",
"Model": "qwen2.5:1.5b-instruct" // plus rapide que 3b
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Administration.Controllers.SearchController": "Debug", "Microsoft.AspNetCore": "Warning"
"administration.Controllers.HelloFreshController": "Debug"
}
} }
},
"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": "*"
} }

View File

@@ -4,6 +4,6 @@
"Model": "qwen2.5:3b-instruct" "Model": "qwen2.5:3b-instruct"
}, },
"ConnectionStrings": { "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"
} }
} }

View File

@@ -33,13 +33,6 @@
const normalize = (s) => (s ?? "").toString().trim().toLowerCase(); const normalize = (s) => (s ?? "").toString().trim().toLowerCase();
const proxify = (url) => url ? `/HelloFresh/ProxyPdf?url=${encodeURIComponent(url)}` : ""; 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();
});

View File

@@ -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; }; 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); } 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) * COLORS for tags (persistées localStorage)
***********************/ ***********************/
@@ -326,28 +339,44 @@ function buildRecipeCardOwned(recipe) {
async function loadRecipes(page = 1) { async function loadRecipes(page = 1) {
try { try {
const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin); const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin);
url.searchParams.set('page', page); url.searchParams.set('count', countPerPage); url.searchParams.set('page', page);
const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); 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 data = await res.json();
const { recipes, currentPage: serverPage, lastPage } = data; const { recipes, currentPage: serverPage, lastPage } = data;
currentPage = serverPage; lastPageGlobal = lastPage; currentPage = serverPage; lastPageGlobal = lastPage;
const grid = document.getElementById('recipeGrid'); grid.innerHTML = ''; const grid = document.getElementById('recipeGrid'); grid.innerHTML = '';
if (!recipes || recipes.length === 0) { if (!recipes || recipes.length === 0) {
grid.innerHTML = '<p class="placeholder">Aucune recette disponible pour le moment.</p>'; grid.innerHTML = '<p class="placeholder">Aucune recette disponible pour le moment.</p>';
renderPagination(currentPage, lastPageGlobal); return; renderPagination(currentPage, lastPageGlobal);
return;
} }
recipes.forEach(r => grid.appendChild(buildRecipeCardList(r))); recipes.forEach(r => grid.appendChild(buildRecipeCardList(r)));
if (window.tippy) { if (window.tippy) {
tippy('[data-tippy-content]', { placement: 'top', arrow: true, delay: [100, 0], theme: 'light-border', maxWidth: 250, allowHTML: true }); tippy('[data-tippy-content]', { placement: 'top', arrow: true, delay: [100, 0], theme: 'light-border', maxWidth: 250, allowHTML: true });
} }
await hydrateIngredientsForPage(recipes); await hydrateIngredientsForPage(recipes);
refreshSelectedBorders(); 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(); refreshDoneFlagsOnVisibleCards();
renderPagination(currentPage, lastPageGlobal); renderPagination(currentPage, lastPageGlobal);
} catch (e) { console.error('loadRecipes error', e); } } catch (e) { console.error('loadRecipes error', e); }
} }
async function loadRecipesOwned({ onEmpty = 'placeholder' } = {}) { async function loadRecipesOwned({ onEmpty = 'placeholder' } = {}) {
const gridOwned = document.getElementById('recipeGridOwned'); if (!gridOwned) return false; const gridOwned = document.getElementById('recipeGridOwned'); if (!gridOwned) return false;
const wasOpen = document.getElementById('recipeOwnedWrap')?.classList.contains('open'); const wasOpen = document.getElementById('recipeOwnedWrap')?.classList.contains('open');
@@ -450,19 +479,40 @@ function anyFilterActive() {
appliedFilters.timeMin != null || appliedFilters.timeMin != null ||
appliedFilters.timeMax != 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 = []; let page = 1, last = 1, all = [];
do { do {
const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin); const url = new URL('/HelloFresh/GetRecipesByPage', window.location.origin);
url.searchParams.set('page', page); url.searchParams.set('count', countPerPage); url.searchParams.set('page', page);
const res = await fetch(url.toString()); if (!res.ok) throw new Error(`HTTP ${res.status}`); 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(); const data = await res.json();
all.push(...(data.recipes || [])); all.push(...(data.recipes || []));
last = data.lastPage || 1; page++; last = data.lastPage || 1;
page++;
} while (page <= last); } 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) { function recipeMatchesFilters(cardMeta) {
const term = normalize(currentSearchTerm); const term = normalize(currentSearchTerm);
if (term && !cardMeta.name.includes(term)) return false; if (term && !cardMeta.name.includes(term)) return false;
@@ -487,14 +537,16 @@ function extractIngredientNames(ingredientsJson) {
} catch { } } catch { }
return [...out]; return [...out];
} }
async function buildMetaFor(recipes) { async function buildMetaFor(recipes) {
const ids = recipes.map(r => r.id).join(','); const ids = recipes.map(r => r.id);
const metaById = {}; const metaById = {};
try { try {
const res = await fetch(`/HelloFresh/GetRecipesDetails?ids=${encodeURIComponent(ids)}`); const details = await fetchDetailsBatched(ids, 150); // ← chunks pour éviter les URLs géantes
const details = await res.json(); // [{id, ingredientsJson}]
details.forEach(d => { metaById[d.id] = { ing: extractIngredientNames(d.ingredientsJson).map(normalize) }; }); details.forEach(d => { metaById[d.id] = { ing: extractIngredientNames(d.ingredientsJson).map(normalize) }; });
} catch (e) { console.warn('details fail', e); } } catch (e) { console.warn('details fail', e); }
recipes.forEach(r => { recipes.forEach(r => {
const tagsVal = (r.tags ?? r.Tags ?? '').trim(); const tagsVal = (r.tags ?? r.Tags ?? '').trim();
const tags = splitTags(tagsVal).map(normalize); const tags = splitTags(tagsVal).map(normalize);
@@ -502,24 +554,45 @@ async function buildMetaFor(recipes) {
const name = normalize(r.name || ''); const name = normalize(r.name || '');
metaById[r.id] = Object.assign({ tags, time, name, ing: [] }, metaById[r.id] || {}); metaById[r.id] = Object.assign({ tags, time, name, ing: [] }, metaById[r.id] || {});
}); });
return metaById; return metaById;
} }
async function applyAllFilters() { 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; CURRENT_CLIENT_LIST = null;
await loadRecipes(1); await loadRecipes(1);
updateActiveFilterBadge(); // peut masquer le badge si rien dactif updateActiveFilterBadge();
return; 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 meta = await buildMetaFor(all);
const filtered = all.filter(r => recipeMatchesFilters(meta[r.id] || {})); const filtered = all.filter(r => recipeMatchesFilters(meta[r.id] || {}));
CURRENT_CLIENT_LIST = filtered; CURRENT_CLIENT_LIST = filtered;
currentPage = 1; currentPage = 1;
lastPageGlobal = Math.max(1, Math.ceil(filtered.length / countPerPage)); lastPageGlobal = Math.max(1, Math.ceil(filtered.length / countPerPage));
await renderClientPage(); await renderClientPage();
updateActiveFilterBadge(); // badge = basé sur appliedFilters UNIQUEMENT updateActiveFilterBadge();
} }
async function renderClientPage() { async function renderClientPage() {
const list = CURRENT_CLIENT_LIST || []; const list = CURRENT_CLIENT_LIST || [];
const start = (currentPage - 1) * countPerPage; const start = (currentPage - 1) * countPerPage;
@@ -809,9 +882,10 @@ document.addEventListener('DOMContentLoaded', async () => {
// Live search // Live search
const searchInput = document.querySelector('#divSearch input'); const searchInput = document.querySelector('#divSearch input');
if (searchInput) { if (searchInput) {
searchInput.addEventListener('input', async (e) => { const debounced = debounce(async () => { await applyAllFilters(); }, 250);
searchInput.addEventListener('input', (e) => {
currentSearchTerm = e.target.value; currentSearchTerm = e.target.value;
await applyAllFilters(); // search est immédiat debounced();
}); });
} }

View File

@@ -12,11 +12,18 @@ const els = { list: null, search: null };
// --- Exclusions par recette --- // --- Exclusions par recette ---
const RECIPE_ING_CACHE = new Map(); // id -> { '1': [noms], '2': [...], ... } const RECIPE_ING_CACHE = new Map(); // id -> { '1': [noms], '2': [...], ... }
const EXCLUDED_BY_RECIPE = new Map(); // id -> Set(noms normalisés) 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” // État “Ingrédients à avoir”
let LAST_ING_TO_HAD = null; let LAST_ING_TO_HAD = null;
let LAST_PORTIONS = 1; let LAST_PORTIONS = 1;
let lastNeededSignature = ""; let lastNeededSignature = "";
let prevNotShippedCount = -1; let prevNotShippedCount = -1;
const IS_CORE_USER = (window.IS_CORE_USER === true || window.IS_CORE_USER === 'true');
const ACCENT_RX = /[\u0300-\u036f]/g; const ACCENT_RX = /[\u0300-\u036f]/g;
const normalizeName = (s) => (s || "").trim().toLowerCase(); const normalizeName = (s) => (s || "").trim().toLowerCase();
@@ -295,21 +302,40 @@ function reapplyCurrentSearch() {
async function loadAndRenderIngredients() { async function loadAndRenderIngredients() {
if (!els.list) return; if (!els.list) return;
els.list.innerHTML = "Chargement…"; els.list.innerHTML = "Chargement…";
try { try {
const res = await fetch("/HelloFresh/AggregatedIngredients"); if (IS_CORE_USER) {
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; } // 🔒 Vue partagée: ingrédients = uniquement Mae/Byakuya
const data = await res.json(); const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`;
ALL_AGG_INGS = Array.isArray(data) ? data : []; const res = await fetch(url, { credentials: "same-origin" });
OWNED_SET = new Set(ALL_AGG_INGS.filter(x => x?.Name && x.Owned === true).map(x => x.Name.trim().toLowerCase())); 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 = ""; els.list.innerHTML = "";
const initialMerged = mergeDuplicates([...ALL_AGG_INGS]); renderItems(list.length ? list : []);
const initial = sortIngredientsForDisplay(applyExclusionsTo(initialMerged)); if (!list.length) els.list.innerHTML = "<em>Aucun ingrédient</em>";
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 = ""; els.list.innerHTML = "";
renderItems(initial.length ? initial : []); renderItems(list.length ? list : []);
if (!initial.length) els.list.innerHTML = "<em>Aucun ingrédient</em>"; if (!list.length) els.list.innerHTML = "<em>Aucun ingrédient</em>";
} catch (e) { console.error("[AggregatedIngredients] exception", e); els.list.innerHTML = "Erreur chargement ingrédients"; } } catch (e) {
console.error("[ingredients] loadAndRenderIngredients exception", e);
els.list.innerHTML = "Erreur chargement ingrédients";
}
} }
// ========== Not shipped (DOM) + BDD ========== // ========== Not shipped (DOM) + BDD ==========
function getNotShippedIngredientsFromDOM(root = document) { function getNotShippedIngredientsFromDOM(root = document) {
const nodes = Array.from(root.querySelectorAll(".ingredient-item-not-shipped")); const nodes = Array.from(root.querySelectorAll(".ingredient-item-not-shipped"));
@@ -536,6 +562,7 @@ function buildRecipeCardList(recipe) {
}); });
if (IS_CORE_USER) {
const usersVal = recipe.Users ?? recipe.users ?? recipe.User ?? recipe.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); const usersArr = Array.isArray(usersVal) ? usersVal.filter(Boolean) : String(usersVal || "").split(/[,•]/).map(s => s.trim()).filter(Boolean);
if (usersArr.length) { if (usersArr.length) {
@@ -544,6 +571,7 @@ function buildRecipeCardList(recipe) {
usersArr.forEach(u => { const span = document.createElement("span"); span.className = "label-badge user-badge"; span.textContent = u; wrap.appendChild(span); }); usersArr.forEach(u => { const span = document.createElement("span"); span.className = "label-badge user-badge"; span.textContent = u; wrap.appendChild(span); });
img?.appendChild(wrap); img?.appendChild(wrap);
} }
}
// Mémorise la dernière recette pour un fallback, via détection auto // Mémorise la dernière recette pour un fallback, via détection auto
const det = detectIngredientsToHadProp(recipe); const det = detectIngredientsToHadProp(recipe);
@@ -558,42 +586,227 @@ function buildRecipeCardList(recipe) {
return card; return card;
} }
const GET_ALL_RECIPES_URL = "/HelloFresh/GetAllRecipes";
async function loadAllRecipesHorizontal() { async function loadAllRecipesHorizontal() {
const strip = document.getElementById("recipeStrip"); const strip = document.getElementById("recipeStrip");
if (!strip) return; if (!strip) return;
// === Vue "partagée" (Mae/Byakuya) : ne montrer QUE leurs recettes ===
if (IS_CORE_USER) {
try { try {
const r = await fetch("/HelloFresh/GetRecipesOwned?isUserImportant=false", { credentials: "same-origin" }); const url = `/HelloFresh/GetRecipesOwnedByUsers?names=${encodeURIComponent(CORE_USERS.join(","))}`;
if (r.ok) { const data = await r.json(); ownedMap.clear(); (data || []).forEach(x => ownedMap.set(String(x.id), clampQty(x.portions || 0))); } const res = await fetch(url, { credentials: "same-origin" });
} catch { }
try {
const res = await fetch(GET_ALL_RECIPES_URL, { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const all = await res.json(); const onlyCore = await res.json();
window.allRecipes = all; window.allRecipes = Array.isArray(onlyCore) ? onlyCore : [];
strip.innerHTML = ""; strip.innerHTML = "";
if (!Array.isArray(all) || all.length === 0) { strip.innerHTML = '<p class="placeholder">Aucune recette disponible.</p>'; updateNeededList(); return; } if (!window.allRecipes.length) {
strip.innerHTML = '<p class="placeholder">Aucune recette (Mae/Byakuya).</p>';
all.sort((a, b) => { updateNeededList();
const ua = String(a.userId ?? ""), ub = String(b.userId ?? ""); return;
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);
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) { } catch (e) {
console.error("loadAllRecipesHorizontal error", e); console.error("loadAllRecipesHorizontal (core) error", e);
strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>';
updateNeededList();
}
return;
}
// === Utilisateur "tiers" : uniquement SES recettes ===
try {
const res = await fetch(GET_MY_OWNED_URL, { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const mine = await res.json();
window.allRecipes = Array.isArray(mine) ? mine : [];
strip.innerHTML = "";
if (!window.allRecipes.length) {
strip.innerHTML = '<p class="placeholder">Vous navez pas encore de recettes sélectionnées.</p>';
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 (mine) error", e);
strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>'; strip.innerHTML = '<p class="placeholder">Erreur de chargement.</p>';
updateNeededList(); 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 // Init
// ====================== // ======================

View File

@@ -55,6 +55,22 @@
})(jQuery); // End of use strict })(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. * 💶 Formate un nombre en chaîne de caractères au format français avec division par 1000.
* Exemples : * Exemples :