using System; using System.Collections.Generic; using System.Text.Json; using Npgsql; using PhoenixLib.Logging; using WingsAPI.Data.Drops; using WingsAPI.Data.Shops; using WingsEmu.DTOs.Maps; using WingsEmu.DTOs.Recipes; using WingsEmu.DTOs.ServerDatas; using WingsEmu.DTOs.Shops; using WingsEmu.Game.Configurations.Miniland; namespace WingsEmu.Plugins.BasicImplementations.ServerConfigs.Persistence; public static class ParserDataPostgresSync { private static bool Enabled => !string.Equals(Environment.GetEnvironmentVariable("PARSER_DB_SYNC"), "false", StringComparison.OrdinalIgnoreCase); private static string BuildConnectionString() { string host = Environment.GetEnvironmentVariable("DATABASE_IP") ?? Environment.GetEnvironmentVariable("POSTGRES_DATABASE_IP") ?? "127.0.0.1"; string port = Environment.GetEnvironmentVariable("DATABASE_PORT") ?? Environment.GetEnvironmentVariable("POSTGRES_DATABASE_PORT") ?? "5432"; string db = Environment.GetEnvironmentVariable("DATABASE_NAME") ?? Environment.GetEnvironmentVariable("POSTGRES_DATABASE_NAME") ?? "game"; string user = Environment.GetEnvironmentVariable("DATABASE_USER") ?? Environment.GetEnvironmentVariable("POSTGRES_DATABASE_USER") ?? "postgres"; string pass = Environment.GetEnvironmentVariable("DATABASE_PASSWORD") ?? Environment.GetEnvironmentVariable("POSTGRES_DATABASE_PASSWORD") ?? "postgres"; return $"Host={host};Port={port};Database={db};Username={user};Password={pass}"; } public static void SyncMapsAndPortals(IReadOnlyList maps, IReadOnlyList portals) { if (!Enabled) { return; } try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@" CREATE TABLE IF NOT EXISTS server_maps ( map_id INT PRIMARY KEY, map_vnum INT, map_name_id INT, map_music_id INT ); CREATE TABLE IF NOT EXISTS server_map_flags ( map_id INT NOT NULL, flag TEXT NOT NULL, PRIMARY KEY(map_id, flag) ); CREATE TABLE IF NOT EXISTS map_portals ( id BIGSERIAL PRIMARY KEY, destination_map_id INT, destination_map_x INT, destination_map_y INT, source_map_id INT, source_map_x INT, source_map_y INT, type INT );", conn, tx)) { cmd.ExecuteNonQuery(); } using (var cmd = new NpgsqlCommand("TRUNCATE TABLE server_map_flags, server_maps, map_portals RESTART IDENTITY;", conn, tx)) { cmd.ExecuteNonQuery(); } foreach (ServerMapDto map in maps) { using var cmd = new NpgsqlCommand("INSERT INTO server_maps(map_id,map_vnum,map_name_id,map_music_id) VALUES (@id,@vnum,@name,@music);", conn, tx); cmd.Parameters.AddWithValue("id", map.Id); cmd.Parameters.AddWithValue("vnum", map.MapVnum); cmd.Parameters.AddWithValue("name", map.NameId); cmd.Parameters.AddWithValue("music", map.MusicId); cmd.ExecuteNonQuery(); if (map.Flags == null) { continue; } foreach (MapFlags flag in map.Flags) { using var fcmd = new NpgsqlCommand("INSERT INTO server_map_flags(map_id,flag) VALUES (@id,@flag);", conn, tx); fcmd.Parameters.AddWithValue("id", map.Id); fcmd.Parameters.AddWithValue("flag", flag.ToString()); fcmd.ExecuteNonQuery(); } } foreach (PortalDTO portal in portals) { using var cmd = new NpgsqlCommand(@"INSERT INTO map_portals(destination_map_id,destination_map_x,destination_map_y,source_map_id,source_map_x,source_map_y,type) VALUES (@dmid,@dmx,@dmy,@smid,@smx,@smy,@type);", conn, tx); cmd.Parameters.AddWithValue("dmid", portal.DestinationMapId); cmd.Parameters.AddWithValue("dmx", portal.DestinationX); cmd.Parameters.AddWithValue("dmy", portal.DestinationY); cmd.Parameters.AddWithValue("smid", portal.SourceMapId); cmd.Parameters.AddWithValue("smx", portal.SourceX); cmd.Parameters.AddWithValue("smy", portal.SourceY); cmd.Parameters.AddWithValue("type", portal.Type); cmd.ExecuteNonQuery(); } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced maps={maps.Count} portals={portals.Count}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync maps/portals", ex); } } public static void SyncMapNpcs(IReadOnlyList npcs) { if (!Enabled) { return; } try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@" CREATE TABLE IF NOT EXISTS map_npcs ( id BIGSERIAL PRIMARY KEY, map_npc_id INT, map_id INT, vnum INT, pos_x INT, pos_y INT, effect_vnum INT, effect_delay INT, dialog_id INT, direction_facing INT );", conn, tx)) { cmd.ExecuteNonQuery(); } using (var cmd = new NpgsqlCommand("TRUNCATE TABLE map_npcs RESTART IDENTITY;", conn, tx)) { cmd.ExecuteNonQuery(); } foreach (MapNpcDTO npc in npcs) { using var cmd = new NpgsqlCommand(@"INSERT INTO map_npcs(map_npc_id,map_id,vnum,pos_x,pos_y,effect_vnum,effect_delay,dialog_id,direction_facing) VALUES (@id,@map,@vnum,@x,@y,@ev,@ed,@dialog,@dir);", conn, tx); cmd.Parameters.AddWithValue("id", npc.Id); cmd.Parameters.AddWithValue("map", npc.MapId); cmd.Parameters.AddWithValue("vnum", npc.NpcVNum); cmd.Parameters.AddWithValue("x", npc.MapX); cmd.Parameters.AddWithValue("y", npc.MapY); cmd.Parameters.AddWithValue("ev", npc.Effect); cmd.Parameters.AddWithValue("ed", npc.EffectDelay); cmd.Parameters.AddWithValue("dialog", npc.Dialog); cmd.Parameters.AddWithValue("dir", npc.Direction); cmd.ExecuteNonQuery(); } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced map_npcs={npcs.Count}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync map_npcs", ex); } } public static void SyncMapMonsters(IReadOnlyList monsters) { if (!Enabled) { return; } try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@" CREATE TABLE IF NOT EXISTS map_monsters ( id BIGSERIAL PRIMARY KEY, map_monster_id INT, map_id INT, vnum INT, map_x INT, map_y INT, direction INT, can_move BOOLEAN ); ALTER TABLE map_monsters ADD COLUMN IF NOT EXISTS direction INT; ALTER TABLE map_monsters ADD COLUMN IF NOT EXISTS can_move BOOLEAN; ALTER TABLE map_monsters ADD COLUMN IF NOT EXISTS map_monster_id INT; ALTER TABLE map_monsters ADD COLUMN IF NOT EXISTS map_id INT; ALTER TABLE map_monsters ADD COLUMN IF NOT EXISTS vnum INT; ALTER TABLE map_monsters ADD COLUMN IF NOT EXISTS map_x INT; ALTER TABLE map_monsters ADD COLUMN IF NOT EXISTS map_y INT;", conn, tx)) { cmd.ExecuteNonQuery(); } using (var cmd = new NpgsqlCommand("TRUNCATE TABLE map_monsters RESTART IDENTITY;", conn, tx)) { cmd.ExecuteNonQuery(); } foreach (MapMonsterDTO monster in monsters) { using var cmd = new NpgsqlCommand(@"INSERT INTO map_monsters(map_monster_id,map_id,vnum,map_x,map_y,direction,can_move) VALUES (@id,@map,@vnum,@x,@y,@dir,@move);", conn, tx); cmd.Parameters.AddWithValue("id", monster.Id); cmd.Parameters.AddWithValue("map", monster.MapId); cmd.Parameters.AddWithValue("vnum", monster.MonsterVNum); cmd.Parameters.AddWithValue("x", monster.MapX); cmd.Parameters.AddWithValue("y", monster.MapY); cmd.Parameters.AddWithValue("dir", monster.Direction); cmd.Parameters.AddWithValue("move", monster.IsMoving); cmd.ExecuteNonQuery(); } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced map_monsters={monsters.Count}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync map_monsters", ex); } } public static void SyncTeleporters(IReadOnlyList teleporters) { if (!Enabled) return; try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@"CREATE TABLE IF NOT EXISTS map_teleporters ( id BIGSERIAL PRIMARY KEY, teleporter_id INT, idx INT, type INT, map_id INT, map_npc_id INT, map_x INT, map_y INT );", conn, tx)) cmd.ExecuteNonQuery(); using (var cmd = new NpgsqlCommand("TRUNCATE TABLE map_teleporters RESTART IDENTITY;", conn, tx)) cmd.ExecuteNonQuery(); foreach (var t in teleporters) { using var cmd = new NpgsqlCommand(@"INSERT INTO map_teleporters(teleporter_id,idx,type,map_id,map_npc_id,map_x,map_y) VALUES (@id,@idx,@type,@map,@npc,@x,@y);", conn, tx); cmd.Parameters.AddWithValue("id", t.Id); cmd.Parameters.AddWithValue("idx", t.Index); cmd.Parameters.AddWithValue("type", (int)t.Type); cmd.Parameters.AddWithValue("map", t.MapId); cmd.Parameters.AddWithValue("npc", t.MapNpcId); cmd.Parameters.AddWithValue("x", t.MapX); cmd.Parameters.AddWithValue("y", t.MapY); cmd.ExecuteNonQuery(); } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced map_teleporters={teleporters.Count}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync map_teleporters", ex); } } public static void SyncShops(IReadOnlyList shops) { if (!Enabled) return; try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@"CREATE TABLE IF NOT EXISTS shops ( id BIGSERIAL PRIMARY KEY, map_npc_id INT, menu_type INT, shop_type INT, name TEXT ); CREATE TABLE IF NOT EXISTS shop_items ( id BIGSERIAL PRIMARY KEY, map_npc_id INT, slot INT, color INT, item_vnum INT, rare INT, type INT, upgrade INT, price INT ); CREATE TABLE IF NOT EXISTS shop_skills ( id BIGSERIAL PRIMARY KEY, map_npc_id INT, skill_vnum INT, slot INT, type INT );", conn, tx)) cmd.ExecuteNonQuery(); using (var cmd = new NpgsqlCommand("TRUNCATE TABLE shops, shop_items, shop_skills RESTART IDENTITY;", conn, tx)) cmd.ExecuteNonQuery(); foreach (var s in shops) { using (var cmd = new NpgsqlCommand("INSERT INTO shops(map_npc_id,menu_type,shop_type,name) VALUES (@npc,@menu,@type,@name);", conn, tx)) { cmd.Parameters.AddWithValue("npc", s.MapNpcId); cmd.Parameters.AddWithValue("menu", s.MenuType); cmd.Parameters.AddWithValue("type", s.ShopType); cmd.Parameters.AddWithValue("name", (object?)s.Name ?? DBNull.Value); cmd.ExecuteNonQuery(); } if (s.Items != null) { foreach (ShopItemDTO it in s.Items) { using var cmd = new NpgsqlCommand(@"INSERT INTO shop_items(map_npc_id,slot,color,item_vnum,rare,type,upgrade,price) VALUES (@npc,@slot,@color,@vnum,@rare,@type,@upg,@price);", conn, tx); cmd.Parameters.AddWithValue("npc", s.MapNpcId); cmd.Parameters.AddWithValue("slot", it.Slot); cmd.Parameters.AddWithValue("color", it.Color); cmd.Parameters.AddWithValue("vnum", it.ItemVNum); cmd.Parameters.AddWithValue("rare", it.Rare); cmd.Parameters.AddWithValue("type", it.Type); cmd.Parameters.AddWithValue("upg", it.Upgrade); cmd.Parameters.AddWithValue("price", (object?)it.Price ?? DBNull.Value); cmd.ExecuteNonQuery(); } } if (s.Skills != null) { foreach (ShopSkillDTO sk in s.Skills) { using var cmd = new NpgsqlCommand("INSERT INTO shop_skills(map_npc_id,skill_vnum,slot,type) VALUES (@npc,@skill,@slot,@type);", conn, tx); cmd.Parameters.AddWithValue("npc", s.MapNpcId); cmd.Parameters.AddWithValue("skill", sk.SkillVNum); cmd.Parameters.AddWithValue("slot", sk.Slot); cmd.Parameters.AddWithValue("type", sk.Type); cmd.ExecuteNonQuery(); } } } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced shops={shops.Count}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync shops", ex); } } public static void SyncItemBoxes(IReadOnlyList itemBoxes) { if (!Enabled) return; try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@"CREATE TABLE IF NOT EXISTS item_boxes ( id BIGSERIAL PRIMARY KEY, item_vnum INT, box_type INT, min_rewards INT, max_rewards INT, shows_raid_panel BOOLEAN ); CREATE TABLE IF NOT EXISTS item_box_items ( id BIGSERIAL PRIMARY KEY, item_vnum INT, probability INT, min_original_rare INT, max_original_rare INT, generated_amount INT, generated_vnum INT, generated_random_rarity BOOLEAN, generated_upgrade INT );", conn, tx)) cmd.ExecuteNonQuery(); using (var cmd = new NpgsqlCommand("TRUNCATE TABLE item_boxes, item_box_items RESTART IDENTITY;", conn, tx)) cmd.ExecuteNonQuery(); foreach (var b in itemBoxes) { using (var cmd = new NpgsqlCommand(@"INSERT INTO item_boxes(item_vnum,box_type,min_rewards,max_rewards,shows_raid_panel) VALUES (@vnum,@type,@min,@max,@raid);", conn, tx)) { cmd.Parameters.AddWithValue("vnum", b.Id); cmd.Parameters.AddWithValue("type", (int)b.ItemBoxType); cmd.Parameters.AddWithValue("min", (object?)b.MinimumRewards ?? DBNull.Value); cmd.Parameters.AddWithValue("max", (object?)b.MaximumRewards ?? DBNull.Value); cmd.Parameters.AddWithValue("raid", b.ShowsRaidBoxPanelOnOpen); cmd.ExecuteNonQuery(); } if (b.Items == null) continue; foreach (var it in b.Items) { using var cmd = new NpgsqlCommand(@"INSERT INTO item_box_items(item_vnum,probability,min_original_rare,max_original_rare,generated_amount,generated_vnum,generated_random_rarity,generated_upgrade) VALUES (@vnum,@prob,@minr,@maxr,@amt,@gv,@rr,@upg);", conn, tx); cmd.Parameters.AddWithValue("vnum", b.Id); cmd.Parameters.AddWithValue("prob", it.Probability); cmd.Parameters.AddWithValue("minr", it.MinimumOriginalItemRare); cmd.Parameters.AddWithValue("maxr", it.MaximumOriginalItemRare); cmd.Parameters.AddWithValue("amt", it.ItemGeneratedAmount); cmd.Parameters.AddWithValue("gv", it.ItemGeneratedVNum); cmd.Parameters.AddWithValue("rr", it.ItemGeneratedRandomRarity); cmd.Parameters.AddWithValue("upg", it.ItemGeneratedUpgrade); cmd.ExecuteNonQuery(); } } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced item_boxes={itemBoxes.Count}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync item_boxes", ex); } } public static void SyncDrops(IReadOnlyList drops) { if (!Enabled) return; try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@"CREATE TABLE IF NOT EXISTS drops ( id BIGSERIAL PRIMARY KEY, drop_id INT, amount INT, drop_chance INT, item_vnum INT, map_id INT, monster_vnum INT, race_type INT, race_sub_type INT );", conn, tx)) cmd.ExecuteNonQuery(); using (var cmd = new NpgsqlCommand("TRUNCATE TABLE drops RESTART IDENTITY;", conn, tx)) cmd.ExecuteNonQuery(); foreach (var d in drops) { using var cmd = new NpgsqlCommand(@"INSERT INTO drops(drop_id,amount,drop_chance,item_vnum,map_id,monster_vnum,race_type,race_sub_type) VALUES (@id,@amount,@chance,@item,@map,@mon,@race,@subrace);", conn, tx); cmd.Parameters.AddWithValue("id", d.Id); cmd.Parameters.AddWithValue("amount", d.Amount); cmd.Parameters.AddWithValue("chance", d.DropChance); cmd.Parameters.AddWithValue("item", d.ItemVNum); cmd.Parameters.AddWithValue("map", (object?)d.MapId ?? DBNull.Value); cmd.Parameters.AddWithValue("mon", (object?)d.MonsterVNum ?? DBNull.Value); cmd.Parameters.AddWithValue("race", (object?)d.RaceType ?? DBNull.Value); cmd.Parameters.AddWithValue("subrace", (object?)d.RaceSubType ?? DBNull.Value); cmd.ExecuteNonQuery(); } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced drops={drops.Count}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync drops", ex); } } public static void SyncMinigames(MinigameConfiguration minigameConfiguration) { if (!Enabled || minigameConfiguration == null) return; try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@"CREATE TABLE IF NOT EXISTS minigame_config ( id BIGSERIAL PRIMARY KEY, minigame_vnum INT, minigame_type INT, minimum_level INT, minimum_reputation INT, rewards_json JSONB ); CREATE TABLE IF NOT EXISTS minigame_scores_holders ( id BIGSERIAL PRIMARY KEY, minigame_type INT, scores_json JSONB ); CREATE TABLE IF NOT EXISTS global_minigame_config ( id BIGSERIAL PRIMARY KEY, config_json JSONB );", conn, tx)) cmd.ExecuteNonQuery(); using (var cmd = new NpgsqlCommand("TRUNCATE TABLE minigame_config, minigame_scores_holders, global_minigame_config RESTART IDENTITY;", conn, tx)) cmd.ExecuteNonQuery(); if (minigameConfiguration.Minigames != null) { foreach (Minigame m in minigameConfiguration.Minigames) { using var cmd = new NpgsqlCommand(@"INSERT INTO minigame_config(minigame_vnum,minigame_type,minimum_level,minimum_reputation,rewards_json) VALUES (@vnum,@type,@minLvl,@minRep,@rewards::jsonb);", conn, tx); cmd.Parameters.AddWithValue("vnum", m.Vnum); cmd.Parameters.AddWithValue("type", (int)m.Type); cmd.Parameters.AddWithValue("minLvl", m.MinimumLevel); cmd.Parameters.AddWithValue("minRep", m.MinimumReputation); cmd.Parameters.AddWithValue("rewards", JsonSerializer.Serialize(m.Rewards)); cmd.ExecuteNonQuery(); } } if (minigameConfiguration.ScoresHolders != null) { foreach (MinigameScoresHolder s in minigameConfiguration.ScoresHolders) { using var cmd = new NpgsqlCommand(@"INSERT INTO minigame_scores_holders(minigame_type,scores_json) VALUES (@type,@scores::jsonb);", conn, tx); cmd.Parameters.AddWithValue("type", (int)s.Type); cmd.Parameters.AddWithValue("scores", JsonSerializer.Serialize(s.Scores)); cmd.ExecuteNonQuery(); } } using (var cmd = new NpgsqlCommand("INSERT INTO global_minigame_config(config_json) VALUES (@cfg::jsonb);", conn, tx)) { cmd.Parameters.AddWithValue("cfg", JsonSerializer.Serialize(minigameConfiguration.Configuration)); cmd.ExecuteNonQuery(); } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced minigames={minigameConfiguration.Minigames?.Count ?? 0} holders={minigameConfiguration.ScoresHolders?.Count ?? 0}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync minigames", ex); } } public static void SyncRecipes(IReadOnlyList recipes) { if (!Enabled) return; try { using var conn = new NpgsqlConnection(BuildConnectionString()); conn.Open(); using var tx = conn.BeginTransaction(); using (var cmd = new NpgsqlCommand(@"CREATE TABLE IF NOT EXISTS recipes ( id BIGSERIAL PRIMARY KEY, recipe_id INT, amount INT, producer_map_npc_id INT, produced_item_vnum INT, producer_item_vnum INT, producer_npc_vnum INT ); CREATE TABLE IF NOT EXISTS recipe_items ( id BIGSERIAL PRIMARY KEY, recipe_id INT, slot INT, item_vnum INT, amount INT );", conn, tx)) cmd.ExecuteNonQuery(); using (var cmd = new NpgsqlCommand("TRUNCATE TABLE recipes, recipe_items RESTART IDENTITY;", conn, tx)) cmd.ExecuteNonQuery(); int fallbackRecipeId = 1; foreach (var r in recipes) { int effectiveRecipeId = r.Id > 0 ? r.Id : fallbackRecipeId; fallbackRecipeId = Math.Max(fallbackRecipeId + 1, effectiveRecipeId + 1); using (var cmd = new NpgsqlCommand(@"INSERT INTO recipes(recipe_id,amount,producer_map_npc_id,produced_item_vnum,producer_item_vnum,producer_npc_vnum) VALUES (@id,@amount,@mapNpc,@produced,@prodItem,@prodNpc);", conn, tx)) { cmd.Parameters.AddWithValue("id", effectiveRecipeId); cmd.Parameters.AddWithValue("amount", r.Amount); cmd.Parameters.AddWithValue("mapNpc", (object?)r.ProducerMapNpcId ?? DBNull.Value); cmd.Parameters.AddWithValue("produced", r.ProducedItemVnum); cmd.Parameters.AddWithValue("prodItem", (object?)r.ProducerItemVnum ?? DBNull.Value); cmd.Parameters.AddWithValue("prodNpc", (object?)r.ProducerNpcVnum ?? DBNull.Value); cmd.ExecuteNonQuery(); } if (r.Items == null) continue; foreach (RecipeItemDTO it in r.Items) { using var cmd = new NpgsqlCommand("INSERT INTO recipe_items(recipe_id,slot,item_vnum,amount) VALUES (@id,@slot,@item,@amount);", conn, tx); cmd.Parameters.AddWithValue("id", effectiveRecipeId); cmd.Parameters.AddWithValue("slot", it.Slot); cmd.Parameters.AddWithValue("item", it.ItemVNum); cmd.Parameters.AddWithValue("amount", it.Amount); cmd.ExecuteNonQuery(); } } tx.Commit(); Log.Info($"[PARSER_DB_SYNC] Synced recipes={recipes.Count}"); } catch (Exception ex) { Log.Error("[PARSER_DB_SYNC] Failed to sync recipes", ex); } } }