Export/import recipes and supplements via JSON files

Recipes:
- Export all: downloads baldav-recipes.json with ingredients + product KBJU
- Export one: button in recipe detail downloads {name}.json
- Import: reads JSON file, creates recipes + missing products

Supplements:
- Export all: downloads baldav-supplements.json
- Import: reads JSON, creates supplements (skips id, gets new one)

Buttons: arrow-down (import) and arrow-up (export) next to + on both screens.
Uses downloadFile() helper for browser download, pickFile() for file selection.
Import matches products by name, creates missing ones from exported KBJU.
This commit is contained in:
4aiman 2026-05-29 21:13:44 +03:00
commit 5ecce90744

View file

@ -1424,7 +1424,11 @@ async function renderRecipes(container) {
<div class="products-screen">
<div class="section-header">
<h2>Рецепты</h2>
<button class="add-btn" id="add-recipe-btn">+</button>
<span style="display:flex;gap:6px">
<button class="add-btn" id="import-recipes-btn" title="Импорт" style="font-size:14px">&#x2B07;</button>
<button class="add-btn" id="export-recipes-btn" title="Экспорт всех" style="font-size:14px">&#x2B06;</button>
<button class="add-btn" id="add-recipe-btn">+</button>
</span>
</div>
<div id="recipe-list" class="prod-list">
${recipes.length ? recipes.map(r => {
@ -1449,6 +1453,20 @@ async function renderRecipes(container) {
container.querySelector("#add-recipe-btn").onclick = () => showRecipeEditorModal(null);
container.querySelector("#export-recipes-btn").onclick = async () => {
const allRecipes = await exportAllRecipes();
const json = JSON.stringify(allRecipes, null, 2);
downloadFile("baldav-recipes.json", json, "application/json");
};
container.querySelector("#import-recipes-btn").onclick = () => {
pickFile(".json", async (text) => {
const data = JSON.parse(text);
await importRecipes(data);
renderRecipes(container);
});
};
container.querySelectorAll(".recipe-card").forEach(card => {
card.onclick = () => {
const rid = parseInt(card.dataset.rid);
@ -1519,6 +1537,7 @@ async function showRecipeDetailModal(recipeId) {
<button id="rd-log" class="btn-primary full-width" style="margin-top:16px">Записать в дневник</button>
<button id="rd-edit" class="btn-secondary full-width" style="margin-top:8px">Редактировать</button>
<button id="rd-export" class="btn-secondary full-width" style="margin-top:8px">Экспорт рецепта</button>
<button id="rd-delete" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить рецепт</button>
`, () => {});
@ -1526,6 +1545,7 @@ async function showRecipeDetailModal(recipeId) {
const overlay = [...document.querySelectorAll(".modal-overlay")].pop();
const logBtn = overlay.querySelector("#rd-log");
const editBtn = overlay.querySelector("#rd-edit");
const exportBtn = overlay.querySelector("#rd-export");
const delBtn = overlay.querySelector("#rd-delete");
logBtn.onclick = async () => {
@ -1536,8 +1556,22 @@ async function showRecipeDetailModal(recipeId) {
renderScreen(state.screen);
};
exportBtn.onclick = async () => {
const ings = await DB.getRecipeIngredients(recipeId);
const exported = {
name: recipe.name, cooking_method_id: recipe.cooking_method_id,
servings: recipe.servings, notes: recipe.notes,
ingredients: ings.map(i => ({
product_name: i.product_name, custom_name: i.custom_name,
amount_g: i.amount_g, cooking_method_id: i.cooking_method_id,
kcal_per_100g: i.kcal_per_100g, protein_per_100g: i.protein_per_100g,
fat_per_100g: i.fat_per_100g, carbs_per_100g: i.carbs_per_100g,
})),
};
downloadFile(`${recipe.name}.json`, JSON.stringify(exported, null, 2), "application/json");
};
editBtn.onclick = () => {
// Close this modal via winStack, then open editor
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1;
history.back();
@ -3397,7 +3431,11 @@ async function renderSupplements(container) {
<div class="products-screen">
<div class="section-header">
<h2>Добавки</h2>
<button class="add-btn" id="add-new-supp">+</button>
<span style="display:flex;gap:6px">
<button class="add-btn" id="import-supps-btn" title="Импорт" style="font-size:14px">&#x2B07;</button>
<button class="add-btn" id="export-supps-btn" title="Экспорт всех" style="font-size:14px">&#x2B06;</button>
<button class="add-btn" id="add-new-supp">+</button>
</span>
</div>
<div id="supp-list" class="prod-list">
${supps.length ? supps.map(s => `
@ -3415,6 +3453,19 @@ async function renderSupplements(container) {
container.querySelector("#add-new-supp").onclick = () => showEditSupplementModal(null);
container.querySelector("#export-supps-btn").onclick = async () => {
const supps = await DB.getSupplements();
downloadFile("baldav-supplements.json", JSON.stringify(supps, null, 2), "application/json");
};
container.querySelector("#import-supps-btn").onclick = () => {
pickFile(".json", async (text) => {
const data = JSON.parse(text);
await importSupplements(data);
renderSupplements(container);
});
};
container.querySelectorAll(".prod-card[data-sid]").forEach(card => {
card.querySelector(".prod-card-header").onclick = async () => {
const sid = parseInt(card.dataset.sid);
@ -4225,6 +4276,89 @@ function addZoomDisplay(map) {
ctrl.addTo(map);
}
// ---------------------------------------------------------------------------
// Export/Import: recipes, supplements
// ---------------------------------------------------------------------------
function downloadFile(filename, content, mimeType) {
const blob = new Blob([content], { type: mimeType || "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
async function exportAllRecipes() {
const recipes = await DB.getRecipes();
const result = [];
for (const r of recipes) {
const ings = await DB.getRecipeIngredients(r.id);
result.push({
name: r.name,
cooking_method_id: r.cooking_method_id,
cooking_method_name: r.cooking_method_name,
servings: r.servings,
notes: r.notes,
ingredients: ings.map(i => ({
product_name: i.product_name,
custom_name: i.custom_name,
amount_g: i.amount_g,
cooking_method_id: i.cooking_method_id,
cooking_method_name: i.cooking_method_name,
// Store product nutrients so recipient can create product if missing
kcal_per_100g: i.kcal_per_100g,
protein_per_100g: i.protein_per_100g,
fat_per_100g: i.fat_per_100g,
carbs_per_100g: i.carbs_per_100g,
})),
});
}
return result;
}
async function importRecipes(data) {
const arr = Array.isArray(data) ? data : [data];
for (const r of arr) {
if (!r.name) continue;
const rid = await DB.saveRecipe(r.name, r.cooking_method_id ?? null, r.servings ?? 1, r.notes ?? null);
if (!rid) continue;
for (const ing of (r.ingredients || [])) {
// Try to find product by name
let pid = 0;
if (ing.product_name) {
const found = await DB.searchProducts(ing.product_name, 1);
if (found.length) {
pid = found[0].id;
} else if (ing.kcal_per_100g != null) {
// Create product from exported data
pid = await DB.addProduct({
name: ing.product_name,
kcal_per_100g: ing.kcal_per_100g,
protein_per_100g: ing.protein_per_100g,
fat_per_100g: ing.fat_per_100g,
carbs_per_100g: ing.carbs_per_100g,
source: "recipe_import",
});
}
}
await DB.addRecipeIngredient(rid, pid, ing.custom_name ?? null, ing.amount_g ?? 100, ing.cooking_method_id ?? null);
}
}
}
async function importSupplements(data) {
const arr = Array.isArray(data) ? data : [data];
for (const s of arr) {
if (!s.name) continue;
// Remove id — will get new one
const clean = { ...s };
delete clean.id;
await DB.addSupplement(clean);
}
}
function pickFile(accept, callback, asBinary) {
console.log("[pickFile] accept:", accept, "asBinary:", asBinary);
const input = document.createElement("input");