Files
recipe-importer/app/templates/import.html
T
admin 8e45d647fd Add bulk import feature and supported sites display
- Two-tab UI: single import (existing) and bulk import
- Bulk mode: paste multiple URLs, choose review-each or auto-import
- Review mode: edit each recipe before importing, option to switch
  to auto mid-way
- Auto mode: scrape and import all without manual review
- Tag option for auto mode: import all tags or none
- Progress table with per-recipe status tracking
- Import targets: Mealie, Tandoor, or both
- Supported sites shown on both tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:06:02 +01:00

1307 lines
45 KiB
HTML

{% extends "base.html" %}
{% block title %}Importálás — Recept Importáló{% endblock %}
{% block head %}
<style>
.recipe-preview { display: none; }
.recipe-preview.visible { display: block; }
.section-divider { border: none; border-top: 1px solid var(--border); margin: 1.2rem 0; }
.recipe-image {
max-width: 300px;
max-height: 200px;
border-radius: var(--radius);
object-fit: cover;
}
.ingredient-header {
display: flex;
gap: 0.5rem;
margin-bottom: 0.3rem;
padding-right: 34px; /* space for remove btn */
}
.ingredient-header span {
font-size: 0.8rem;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ingredient-header .col-qty { width: 60px; flex-shrink: 0; }
.ingredient-header .col-unit { width: 80px; flex-shrink: 0; }
.ingredient-header .col-food { flex: 1; }
.ingredient-header .col-extra { width: 120px; flex-shrink: 0; }
.ingredient-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.4rem;
}
.ingredient-row input { margin-bottom: 0; }
.ingredient-row .ing-qty { width: 60px; flex-shrink: 0; }
.ingredient-row .ing-unit { width: 80px; flex-shrink: 0; }
.ingredient-row .ing-food { flex: 1; }
.ingredient-row .ing-extra { width: 120px; flex-shrink: 0; }
.ingredient-row button {
background: var(--danger);
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
}
.ingredient-group {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.8rem 0 0.3rem;
}
.ingredient-group input {
margin-bottom: 0;
flex: 1;
font-weight: 600;
color: var(--accent);
border-style: dashed;
}
.ingredient-group button {
background: var(--danger);
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
}
.instruction-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.instruction-row .step-num {
background: var(--accent);
color: #fff;
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 700;
flex-shrink: 0;
margin-top: 0.4rem;
}
.instruction-row textarea { margin-bottom: 0; flex: 1; min-height: 60px; }
.instruction-row button {
background: var(--danger);
border: none;
color: #fff;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
flex-shrink: 0;
margin-top: 0.4rem;
}
.add-btn {
background: var(--surface2);
border: 1px dashed var(--border);
color: var(--text-dim);
padding: 0.4rem 0.8rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
transition: border-color 0.15s;
}
.add-btn:hover { border-color: var(--accent); color: var(--text); }
/* Tags */
.tag-section-label {
font-size: 0.8rem;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: 0.3rem;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
min-height: 28px;
}
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.85rem;
line-height: 1.2;
cursor: pointer;
transition: opacity 0.15s;
user-select: none;
}
.tag-chip:hover { opacity: 0.8; }
.tag-chip.tag-active {
background: var(--success, #2ea043);
color: #fff;
}
.tag-chip.tag-inactive {
background: var(--surface2);
color: var(--text-dim);
border: 1px solid var(--border);
}
.tag-search-wrap {
position: relative;
margin-top: 0.5rem;
}
.tag-search-wrap input {
margin-bottom: 0;
width: 100%;
}
.tag-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
max-height: 200px;
overflow-y: auto;
z-index: 10;
display: none;
}
.tag-dropdown.open { display: block; }
.tag-dropdown-item {
padding: 0.4rem 0.6rem;
cursor: pointer;
font-size: 0.9rem;
display: flex;
justify-content: space-between;
}
.tag-dropdown-item:hover { background: var(--surface); }
.tag-dropdown-item .tag-source {
color: var(--text-dim);
font-size: 0.8rem;
}
.tag-dropdown-item.tag-add-new { color: var(--accent); font-style: italic; }
.result-card { display: none; }
.result-card.visible { display: block; }
/* Tab bar */
.tab-bar {
display: flex;
gap: 0;
margin-bottom: 1.2rem;
border-bottom: 2px solid var(--border);
}
.tab-btn {
background: none;
border: none;
color: var(--text-dim);
padding: 0.6rem 1.2rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Radio labels */
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--text);
margin-right: 1rem;
}
.radio-label input[type="radio"] {
accent-color: var(--accent);
width: auto;
margin: 0;
}
/* Bulk progress table */
.bulk-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-top: 0.5rem;
}
.bulk-table th, .bulk-table td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.bulk-table th {
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.03em;
}
.bulk-table tr.row-active { background: rgba(233,69,96,0.08); }
.bulk-table .url-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
.status-pending { background: var(--surface2); color: var(--text-dim); }
.status-scraping { background: rgba(52,152,219,0.2); color: #3498db; }
.status-reviewing { background: rgba(243,156,18,0.2); color: var(--warning); }
.status-importing { background: rgba(52,152,219,0.2); color: #3498db; }
.status-done { background: rgba(46,204,113,0.2); color: var(--success); }
.status-error { background: rgba(231,76,60,0.2); color: var(--danger); }
.status-skipped { background: var(--surface2); color: var(--text-dim); }
.status-duplicate { background: rgba(243,156,18,0.2); color: var(--warning); }
.btn-sm {
padding: 0.3rem 0.8rem;
font-size: 0.85rem;
}
.supported-sites {
color: var(--accent);
font-weight: 600;
}
.bulk-options { margin-bottom: 1rem; }
.bulk-options .option-row { margin-bottom: 0.5rem; }
.bulk-tag-option {
margin-top: 0.3rem;
margin-left: 1.6rem;
padding: 0.5rem 0.8rem;
background: var(--surface2);
border-radius: var(--radius);
font-size: 0.85rem;
}
.bulk-summary {
margin-top: 1rem;
padding: 0.8rem;
background: var(--surface2);
border-radius: var(--radius);
}
</style>
{% endblock %}
{% block content %}
<!-- Step 1: URL input -->
<div class="card">
<div class="tab-bar">
<button class="tab-btn active" onclick="switchTab('single')">Egyedi importálás</button>
<button class="tab-btn" onclick="switchTab('bulk')">Tömeges importálás</button>
</div>
<!-- Single import tab -->
<div id="tabSingle">
<p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.8rem;">
Támogatott oldalak: <span class="supported-sites">{{ supported_sites | join(', ') }}</span> + egyéb (schema.org)
</p>
<div class="flex">
<input type="url" id="recipeUrl" class="grow" style="margin-bottom:0"
placeholder="https://www.mindmegette.hu/recept/brassoi">
<button class="btn btn-primary" id="scrapeBtn" onclick="scrapeRecipe()">
Beolvasás
</button>
</div>
<div id="scrapeStatus" class="mt-1"></div>
</div>
<!-- Bulk import tab -->
<div id="tabBulk" style="display:none">
<p style="font-size:0.85rem;color:var(--text-dim);margin-bottom:0.8rem;">
Támogatott oldalak: <span class="supported-sites">{{ supported_sites | join(', ') }}</span> + egyéb (schema.org)
</p>
<label for="bulkUrls">URL-ek (soronként egy)</label>
<textarea id="bulkUrls" rows="6" placeholder="https://www.nosalty.hu/recept/chilis-bab&#10;https://streetkitchen.hu/receptek/...&#10;https://www.mindmegette.hu/recept/..."></textarea>
<div class="bulk-options">
<div class="option-row">
<label class="radio-label">
<input type="radio" name="bulkMode" value="review" checked onchange="onBulkModeChange()">
Receptek egyenkénti áttekintése
</label>
<label class="radio-label">
<input type="radio" name="bulkMode" value="auto" onchange="onBulkModeChange()">
Automatikus importálás
</label>
</div>
<div class="bulk-tag-option" id="bulkTagOption" style="display:none">
Címkék:
<label class="radio-label" style="margin-left:0.5rem">
<input type="radio" name="bulkTags" value="all" checked>
Összes címke importálása
</label>
<label class="radio-label">
<input type="radio" name="bulkTags" value="none">
Címkék nélkül
</label>
</div>
</div>
<div class="flex" style="gap:0.5rem;flex-wrap:wrap;">
{% if has_mealie %}
<button class="btn btn-success" onclick="startBulk('mealie')">Importálás Mealie-be</button>
{% endif %}
{% if has_tandoor %}
<button class="btn btn-success" onclick="startBulk('tandoor')">Importálás Tandoor-ba</button>
{% endif %}
{% if has_mealie and has_tandoor %}
<button class="btn btn-success" onclick="startBulk('both')">Importálás mindkettőbe</button>
{% endif %}
</div>
<!-- Progress section -->
<div id="bulkProgress" style="display:none; margin-top:1.2rem;">
<div class="flex" style="align-items:center;gap:0.8rem;margin-bottom:0.5rem;flex-wrap:wrap;">
<strong id="bulkCounter" style="font-size:0.9rem;"></strong>
<button class="btn btn-secondary btn-sm" id="bulkAutoRemaining" style="display:none"
onclick="autoImportRemaining()">Maradékot automatikusan</button>
<button class="btn btn-secondary btn-sm" id="bulkStopBtn" style="display:none"
onclick="stopBulk()">Megállítás</button>
</div>
<table class="bulk-table">
<thead>
<tr>
<th style="width:30px">#</th>
<th>URL</th>
<th>Cím</th>
<th style="width:110px">Állapot</th>
<th>Eredmény</th>
</tr>
</thead>
<tbody id="bulkTableBody"></tbody>
</table>
<div id="bulkSummary" class="bulk-summary" style="display:none"></div>
</div>
</div>
</div>
<!-- Step 2: Editable preview -->
<div class="recipe-preview" id="previewCard">
<div class="card">
<h2>Recept adatai</h2>
<div class="flex flex-wrap mb-2">
<div class="grow">
<label for="recipeTitle">Név</label>
<input type="text" id="recipeTitle">
<label for="recipeDesc">Leírás</label>
<textarea id="recipeDesc" rows="2"></textarea>
</div>
<div>
<img id="recipeImage" class="recipe-image" src="" alt="">
</div>
</div>
<hr class="section-divider">
<!-- Ingredients -->
<label>Hozzávalók</label>
<div class="ingredient-header">
<span class="col-qty">Menny.</span>
<span class="col-unit">Egység</span>
<span class="col-food">Hozzávaló</span>
<span class="col-extra">Megjegyzés</span>
</div>
<div id="ingredientsList"></div>
<div class="flex gap-1 mt-1 mb-2">
<button class="add-btn" onclick="addIngredient({})">+ Hozzávaló</button>
<button class="add-btn" onclick="addIngredientGroup('')">+ Csoport</button>
</div>
<hr class="section-divider">
<!-- Instructions -->
<label>Elkészítés</label>
<div id="instructionsList"></div>
<button class="add-btn mt-1 mb-2" onclick="addInstruction('')">+ Lépés hozzáadása</button>
<hr class="section-divider">
<!-- Tags -->
<label>Címkék</label>
<div class="tag-section-label">Hozzáadásra kerül:</div>
<div id="tagsActive" class="tag-chips mb-1"></div>
<div class="tag-search-wrap">
<input type="text" id="tagSearch" placeholder="Címke keresése / hozzáadása..."
autocomplete="off" oninput="onTagSearch()" onfocus="onTagSearch()" onkeydown="onTagKeydown(event)">
<div id="tagDropdown" class="tag-dropdown"></div>
</div>
<div class="tag-section-label" style="margin-top:0.6rem;">Weboldalról (kattints a hozzáadáshoz):</div>
<div id="tagsInactive" class="tag-chips"></div>
<hr class="section-divider">
<!-- Single mode action buttons -->
<div class="flex mt-2" id="singleActions">
{% if has_mealie %}
<button class="btn btn-success" id="sendMealieBtn" onclick="sendToMealie()">
Importálás Mealie-be
</button>
{% endif %}
{% if has_tandoor %}
<button class="btn btn-success" id="sendTandoorBtn" onclick="sendToTandoor()">
Importálás Tandoor-ba
</button>
{% endif %}
<span id="sendStatus"></span>
</div>
<!-- Bulk mode action buttons -->
<div class="flex mt-2" id="bulkActions" style="display:none;gap:0.5rem;">
<button class="btn btn-success" id="bulkApproveBtn" onclick="approveAndContinue()">
Importálás és tovább &rarr;
</button>
<button class="btn btn-secondary" onclick="skipCurrent()">
Kihagyás &rarr;
</button>
<span id="bulkSendStatus"></span>
</div>
</div>
</div>
<!-- Step 3: Result (single mode) -->
<div class="result-card" id="resultCard">
<div class="card">
<h2 class="text-success">Recept sikeresen importálva!</h2>
<p class="mt-1" id="resultLinks"></p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentRecipe = null;
// =========================================================================
// Tab switching
// =========================================================================
function switchTab(tab) {
document.getElementById('tabSingle').style.display = tab === 'single' ? '' : 'none';
document.getElementById('tabBulk').style.display = tab === 'bulk' ? '' : 'none';
document.querySelectorAll('.tab-btn').forEach((btn, i) => {
btn.classList.toggle('active', (i === 0 && tab === 'single') || (i === 1 && tab === 'bulk'));
});
document.getElementById('previewCard').classList.remove('visible');
document.getElementById('resultCard').classList.remove('visible');
showSingleButtons();
}
function onBulkModeChange() {
const mode = document.querySelector('input[name="bulkMode"]:checked').value;
document.getElementById('bulkTagOption').style.display = mode === 'auto' ? '' : 'none';
}
// =========================================================================
// Single import (existing)
// =========================================================================
async function scrapeRecipe() {
const url = document.getElementById('recipeUrl').value.trim();
if (!url) return;
const btn = document.getElementById('scrapeBtn');
const status = document.getElementById('scrapeStatus');
btn.disabled = true;
status.innerHTML = '<span class="spinner"></span> Beolvasás folyamatban...';
document.getElementById('previewCard').classList.remove('visible');
document.getElementById('resultCard').classList.remove('visible');
Object.keys(importedLinks).forEach(k => delete importedLinks[k]);
try {
const form = new FormData();
form.append('url', url);
const resp = await fetch('/scrape', { method: 'POST', body: form });
const data = await resp.json();
if (!data.ok) {
status.innerHTML = '<span class="text-danger">Hiba: ' + data.error + '</span>';
btn.disabled = false;
return;
}
currentRecipe = data.data;
populatePreview(currentRecipe);
showSingleButtons();
document.getElementById('previewCard').classList.add('visible');
const warnings = [];
if (data.duplicate) {
warnings.push('Mealie: <a href="' + escHtml(data.duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.duplicate.name) + '</a>');
}
if (data.tandoor_duplicate) {
warnings.push('Tandoor: <a href="' + escHtml(data.tandoor_duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.tandoor_duplicate.name) + '</a>');
}
if (warnings.length > 0) {
status.innerHTML = '<span class="text-warning">&#9888; Ez a recept már létezik: ' + warnings.join(' | ') + '</span>';
} else {
status.innerHTML = '<span class="text-success">&#10003; Beolvasva</span>';
}
} catch (e) {
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
}
btn.disabled = false;
}
function populatePreview(r) {
document.getElementById('recipeTitle').value = r.title || '';
document.getElementById('recipeDesc').value = r.description || '';
const img = document.getElementById('recipeImage');
if (r.image_url) {
img.src = r.image_url;
img.style.display = 'block';
} else {
img.style.display = 'none';
}
// Ingredients
const ingList = document.getElementById('ingredientsList');
ingList.innerHTML = '';
(r.ingredients || []).forEach(i => addIngredient(i));
// Instructions
const instList = document.getElementById('instructionsList');
instList.innerHTML = '';
(r.instructions || []).forEach(t => addInstruction(t));
// Tags — scraped tags go to inactive (user must opt-in)
document.getElementById('tagsActive').innerHTML = '';
document.getElementById('tagsInactive').innerHTML = '';
(r.tags || []).forEach(t => addTagChip(t, false));
}
function addIngredient(item) {
if (typeof item === 'string') item = { food: item };
// Group header marker
if (item.group !== undefined && item.food === undefined) {
addIngredientGroup(item.group);
return;
}
const list = document.getElementById('ingredientsList');
const row = document.createElement('div');
row.className = 'ingredient-row';
row.innerHTML = '<input type="text" class="ing-qty" placeholder="" value="' + escHtml(item.quantity || '') + '">'
+ '<input type="text" class="ing-unit" placeholder="" value="' + escHtml(item.unit || '') + '">'
+ '<input type="text" class="ing-food" placeholder="Hozzávaló" value="' + escHtml(item.food || '') + '">'
+ '<input type="text" class="ing-extra" placeholder="" value="' + escHtml(item.extra || '') + '">'
+ '<button onclick="this.parentElement.remove()">&#10005;</button>';
list.appendChild(row);
}
function addIngredientGroup(name) {
const list = document.getElementById('ingredientsList');
const row = document.createElement('div');
row.className = 'ingredient-group';
row.innerHTML = '<input type="text" class="ing-group-name" placeholder="Csoport neve" value="' + escHtml(name || '') + '">'
+ '<button onclick="this.parentElement.remove()">&#10005;</button>';
list.appendChild(row);
}
function addInstruction(value) {
const list = document.getElementById('instructionsList');
const idx = list.children.length + 1;
const row = document.createElement('div');
row.className = 'instruction-row';
row.innerHTML = '<span class="step-num">' + idx + '</span>'
+ '<textarea>' + escHtml(value) + '</textarea>'
+ '<button onclick="removeInstruction(this)">&#10005;</button>';
list.appendChild(row);
}
function removeInstruction(btn) {
btn.closest('.instruction-row').remove();
renumberInstructions();
}
function renumberInstructions() {
document.querySelectorAll('#instructionsList .step-num').forEach((el, i) => {
el.textContent = i + 1;
});
}
function gatherRecipe() {
const ingredients = [];
document.querySelectorAll('#ingredientsList > div').forEach(el => {
if (el.classList.contains('ingredient-group')) {
const name = el.querySelector('.ing-group-name').value.trim();
if (name) ingredients.push({ group: name });
} else if (el.classList.contains('ingredient-row')) {
const qty = el.querySelector('.ing-qty').value.trim();
const unit = el.querySelector('.ing-unit').value.trim();
const food = el.querySelector('.ing-food').value.trim();
const extra = el.querySelector('.ing-extra').value.trim();
if (food || qty) {
ingredients.push({ quantity: qty, unit: unit, food: food, extra: extra });
}
}
});
const instructions = [];
document.querySelectorAll('#instructionsList .instruction-row textarea').forEach(ta => {
const v = ta.value.trim();
if (v) instructions.push(v);
});
const tags = [];
document.querySelectorAll('#tagsActive .tag-chip').forEach(el => {
tags.push(el.dataset.tag);
});
return {
title: document.getElementById('recipeTitle').value.trim(),
description: document.getElementById('recipeDesc').value.trim(),
image_url: currentRecipe ? currentRecipe.image_url : null,
ingredients: ingredients,
instructions: instructions,
tags: tags,
original_url: currentRecipe ? currentRecipe.original_url : '',
};
}
const importedLinks = {};
async function sendToMealie() {
const recipe = gatherRecipe();
if (!recipe.title) { alert('A recept neve kötelező!'); return; }
const btn = document.getElementById('sendMealieBtn');
const status = document.getElementById('sendStatus');
btn.disabled = true;
status.innerHTML = '<span class="spinner"></span> Importálás Mealie-be...';
try {
const resp = await fetch('/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
const data = await resp.json();
if (!data.ok) {
status.innerHTML = '<span class="text-danger">Hiba: ' + data.error + '</span>';
btn.disabled = false;
return;
}
status.innerHTML = '<span class="text-success">&#10003; Mealie kész</span>';
importedLinks['Mealie'] = data.url;
showResultCard();
} catch (e) {
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
}
btn.disabled = false;
}
async function sendToTandoor() {
const recipe = gatherRecipe();
if (!recipe.title) { alert('A recept neve kötelező!'); return; }
const btn = document.getElementById('sendTandoorBtn');
const status = document.getElementById('sendStatus');
btn.disabled = true;
status.innerHTML = '<span class="spinner"></span> Importálás Tandoor-ba...';
try {
const resp = await fetch('/send-tandoor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
const data = await resp.json();
if (!data.ok) {
status.innerHTML = '<span class="text-danger">Hiba: ' + data.error + '</span>';
btn.disabled = false;
return;
}
status.innerHTML = '<span class="text-success">&#10003; Tandoor kész</span>';
importedLinks['Tandoor'] = data.url;
showResultCard();
} catch (e) {
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
}
btn.disabled = false;
}
function showResultCard() {
const links = Object.entries(importedLinks).map(([name, url]) =>
'<a href="' + escHtml(url) + '" target="_blank" style="color:var(--accent);">Megnyitás ' + name + '-ben &rarr;</a>'
).join('<br>');
document.getElementById('resultLinks').innerHTML = links;
document.getElementById('resultCard').classList.add('visible');
}
// =========================================================================
// Tag management
// =========================================================================
let existingTags = { mealie: [], tandoor: [] };
async function loadExistingTags() {
try {
const resp = await fetch('/tags');
if (resp.ok) existingTags = await resp.json();
} catch (e) { /* ignore */ }
}
function tagExistsIn(name, containerId) {
const container = document.getElementById(containerId);
for (const c of container.querySelectorAll('.tag-chip')) {
if (c.dataset.tag.toLowerCase() === name.toLowerCase()) return true;
}
return false;
}
function addTagChip(name, active) {
name = name.trim();
if (!name) return;
if (tagExistsIn(name, 'tagsActive') || tagExistsIn(name, 'tagsInactive')) return;
const chip = document.createElement('span');
chip.dataset.tag = name;
chip.textContent = name;
chip.onclick = function() { toggleTag(this); };
if (active) {
chip.className = 'tag-chip tag-active';
document.getElementById('tagsActive').appendChild(chip);
} else {
chip.className = 'tag-chip tag-inactive';
document.getElementById('tagsInactive').appendChild(chip);
}
}
function toggleTag(chip) {
if (chip.classList.contains('tag-active')) {
chip.classList.remove('tag-active');
chip.classList.add('tag-inactive');
document.getElementById('tagsInactive').appendChild(chip);
} else {
chip.classList.remove('tag-inactive');
chip.classList.add('tag-active');
document.getElementById('tagsActive').appendChild(chip);
}
}
function getAllTagNames() {
const names = [];
document.querySelectorAll('#tagsActive .tag-chip, #tagsInactive .tag-chip').forEach(el => {
names.push(el.dataset.tag.toLowerCase());
});
return names;
}
function onTagSearch() {
const input = document.getElementById('tagSearch');
const dropdown = document.getElementById('tagDropdown');
const q = input.value.trim().toLowerCase();
if (!q) { dropdown.classList.remove('open'); return; }
const allNames = getAllTagNames();
const seen = {};
for (const t of existingTags.mealie || []) {
const k = t.toLowerCase();
if (!seen[k]) seen[k] = { name: t, sources: [] };
seen[k].sources.push('M');
}
for (const t of existingTags.tandoor || []) {
const k = t.toLowerCase();
if (!seen[k]) seen[k] = { name: t, sources: [] };
seen[k].sources.push('T');
}
const matches = Object.values(seen)
.filter(e => e.name.toLowerCase().includes(q) && !allNames.includes(e.name.toLowerCase()))
.slice(0, 10);
let html = '';
for (const m of matches) {
const src = m.sources.join('+');
html += '<div class="tag-dropdown-item" onclick="selectTag(\'' + escHtml(m.name).replace(/'/g, "\\'") + '\')">'
+ '<span>' + escHtml(m.name) + '</span>'
+ '<span class="tag-source">' + src + '</span></div>';
}
const exactExists = matches.some(m => m.name.toLowerCase() === q) || allNames.includes(q);
if (!exactExists && q) {
html += '<div class="tag-dropdown-item tag-add-new" onclick="selectTag(\'' + escHtml(input.value.trim()).replace(/'/g, "\\'") + '\')">'
+ '+ &quot;' + escHtml(input.value.trim()) + '&quot; hozzáadása</div>';
}
dropdown.innerHTML = html;
dropdown.classList.toggle('open', html.length > 0);
}
function selectTag(name) {
addTagChip(name, true);
const input = document.getElementById('tagSearch');
input.value = '';
document.getElementById('tagDropdown').classList.remove('open');
input.focus();
}
function onTagKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
const val = e.target.value.trim();
if (val) {
addTagChip(val, true);
e.target.value = '';
document.getElementById('tagDropdown').classList.remove('open');
}
} else if (e.key === 'Escape') {
document.getElementById('tagDropdown').classList.remove('open');
}
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.tag-search-wrap')) {
document.getElementById('tagDropdown').classList.remove('open');
}
});
loadExistingTags();
// =========================================================================
// Bulk import
// =========================================================================
const bulkState = {
urls: [],
currentIndex: -1,
mode: 'review', // 'review' | 'auto'
target: '', // 'mealie' | 'tandoor' | 'both'
autoTags: true,
running: false,
stopped: false,
results: [], // {url, status, title, error, links}
scrapeCache: [], // cached scrape responses for review→auto switch
};
const STATUS_LABELS = {
pending: ['Várakozik', 'status-pending'],
scraping: ['Beolvasás...', 'status-scraping'],
reviewing: ['Átnézés...', 'status-reviewing'],
importing: ['Importálás...', 'status-importing'],
done: ['Kész', 'status-done'],
error: ['Hiba', 'status-error'],
skipped: ['Kihagyva', 'status-skipped'],
duplicate: ['Duplikátum', 'status-duplicate'],
};
function showBulkButtons() {
document.getElementById('singleActions').style.display = 'none';
document.getElementById('bulkActions').style.display = 'flex';
}
function showSingleButtons() {
document.getElementById('singleActions').style.display = '';
document.getElementById('bulkActions').style.display = 'none';
}
function isValidUrl(str) {
try { const u = new URL(str); return u.protocol === 'http:' || u.protocol === 'https:'; }
catch { return false; }
}
function shortUrl(url) {
try {
const u = new URL(url);
const path = u.pathname.length > 35 ? u.pathname.substring(0, 35) + '...' : u.pathname;
return u.hostname + path;
} catch { return url.substring(0, 50); }
}
function updateRowStatus(idx, status, errorMsg) {
const row = document.getElementById('bulk-row-' + idx);
if (!row) return;
const td = row.querySelector('.bulk-status');
const [label, cls] = STATUS_LABELS[status] || ['?', 'status-pending'];
let html = '<span class="status-badge ' + cls + '">' + label + '</span>';
if (status === 'error' && errorMsg) {
html += '<br><small class="text-danger" style="font-size:0.75rem">' + escHtml(errorMsg).substring(0, 80) + '</small>';
}
td.innerHTML = html;
bulkState.results[idx].status = status;
}
function updateRowTitle(idx, title) {
const row = document.getElementById('bulk-row-' + idx);
if (!row) return;
row.querySelector('.bulk-title').textContent = title || '';
bulkState.results[idx].title = title || '';
}
function updateRowLinks(idx, links) {
const row = document.getElementById('bulk-row-' + idx);
if (!row) return;
const td = row.querySelector('.bulk-links');
const parts = [];
for (const [name, url] of Object.entries(links)) {
parts.push('<a href="' + escHtml(url) + '" target="_blank" style="color:var(--accent);font-size:0.8rem">' + name + '</a>');
}
td.innerHTML = parts.join(' ');
}
function updateBulkCounter() {
const total = bulkState.urls.length;
const done = bulkState.results.filter(r => r.status === 'done').length;
const errors = bulkState.results.filter(r => r.status === 'error').length;
const skipped = bulkState.results.filter(r => r.status === 'skipped' || r.status === 'duplicate').length;
let text = done + ' / ' + total + ' kész';
const parts = [];
if (errors > 0) parts.push(errors + ' hiba');
if (skipped > 0) parts.push(skipped + ' kihagyva');
if (parts.length > 0) text += ' (' + parts.join(', ') + ')';
document.getElementById('bulkCounter').textContent = text;
}
function showBulkSummary() {
const total = bulkState.urls.length;
const done = bulkState.results.filter(r => r.status === 'done').length;
const errors = bulkState.results.filter(r => r.status === 'error').length;
const skipped = bulkState.results.filter(r => r.status === 'skipped').length;
const dups = bulkState.results.filter(r => r.status === 'duplicate').length;
let html = '<strong>Összegzés:</strong> ' + total + ' URL feldolgozva &mdash; ';
html += '<span class="text-success">' + done + ' importálva</span>';
if (errors > 0) html += ', <span class="text-danger">' + errors + ' hiba</span>';
if (skipped > 0) html += ', ' + skipped + ' kihagyva';
if (dups > 0) html += ', ' + dups + ' duplikátum';
const summary = document.getElementById('bulkSummary');
summary.innerHTML = html;
summary.style.display = '';
}
function startBulk(target) {
const text = document.getElementById('bulkUrls').value.trim();
if (!text) { alert('Add meg az URL-eket!'); return; }
// Parse & validate URLs
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const invalid = lines.filter(l => !isValidUrl(l));
if (invalid.length > 0) {
alert('Érvénytelen URL-ek:\n\n' + invalid.join('\n'));
return;
}
// Deduplicate
const urls = [...new Set(lines)];
if (urls.length === 0) return;
const mode = document.querySelector('input[name="bulkMode"]:checked').value;
const autoTags = document.querySelector('input[name="bulkTags"]:checked').value === 'all';
// Init state
bulkState.urls = urls;
bulkState.currentIndex = -1;
bulkState.mode = mode;
bulkState.target = target;
bulkState.autoTags = autoTags;
bulkState.running = true;
bulkState.stopped = false;
bulkState.results = urls.map(u => ({ url: u, status: 'pending', title: '', error: '', links: {} }));
bulkState.scrapeCache = new Array(urls.length).fill(null);
// Build table
const tbody = document.getElementById('bulkTableBody');
tbody.innerHTML = '';
urls.forEach((u, i) => {
const tr = document.createElement('tr');
tr.id = 'bulk-row-' + i;
tr.innerHTML = '<td>' + (i + 1) + '</td>'
+ '<td class="url-cell" title="' + escHtml(u) + '">' + escHtml(shortUrl(u)) + '</td>'
+ '<td class="bulk-title"></td>'
+ '<td class="bulk-status"><span class="status-badge status-pending">Várakozik</span></td>'
+ '<td class="bulk-links"></td>';
tbody.appendChild(tr);
});
// Show progress, hide preview
document.getElementById('bulkProgress').style.display = '';
document.getElementById('bulkSummary').style.display = 'none';
document.getElementById('bulkStopBtn').style.display = '';
document.getElementById('bulkAutoRemaining').style.display = mode === 'review' ? '' : 'none';
document.getElementById('previewCard').classList.remove('visible');
document.getElementById('resultCard').classList.remove('visible');
// Disable start buttons
document.querySelectorAll('#tabBulk .btn-success').forEach(b => b.disabled = true);
updateBulkCounter();
processNext();
}
function processNext() {
if (bulkState.stopped) {
finishBulk();
return;
}
bulkState.currentIndex++;
const idx = bulkState.currentIndex;
if (idx >= bulkState.urls.length) {
finishBulk();
return;
}
// Highlight active row
document.querySelectorAll('.bulk-table tr.row-active').forEach(r => r.classList.remove('row-active'));
const row = document.getElementById('bulk-row-' + idx);
if (row) row.classList.add('row-active');
scrapeForBulk(idx);
}
async function scrapeForBulk(idx) {
updateRowStatus(idx, 'scraping');
updateBulkCounter();
try {
const form = new FormData();
form.append('url', bulkState.urls[idx]);
const resp = await fetch('/scrape', { method: 'POST', body: form });
const data = await resp.json();
if (!data.ok) {
updateRowStatus(idx, 'error', data.error || 'Ismeretlen hiba');
updateBulkCounter();
processNext();
return;
}
updateRowTitle(idx, data.data.title);
bulkState.scrapeCache[idx] = data;
if (bulkState.mode === 'auto') {
// Check duplicates
const isDup = isDuplicateForTarget(data, bulkState.target);
if (isDup) {
updateRowStatus(idx, 'duplicate');
updateBulkCounter();
processNext();
return;
}
// Strip tags if needed
const recipe = data.data;
if (!bulkState.autoTags) {
recipe.tags = [];
}
await sendForBulk(idx, recipe, data.duplicate, data.tandoor_duplicate);
} else {
// Review mode: show preview
currentRecipe = data.data;
populatePreview(data.data);
showBulkButtons();
document.getElementById('previewCard').classList.add('visible');
document.getElementById('previewCard').scrollIntoView({ behavior: 'smooth' });
// Show duplicate warning in bulk status
const bulkStatus = document.getElementById('bulkSendStatus');
const dupWarnings = [];
if (data.duplicate && (bulkState.target === 'mealie' || bulkState.target === 'both')) {
dupWarnings.push('Mealie: ' + escHtml(data.duplicate.name));
}
if (data.tandoor_duplicate && (bulkState.target === 'tandoor' || bulkState.target === 'both')) {
dupWarnings.push('Tandoor: ' + escHtml(data.tandoor_duplicate.name));
}
if (dupWarnings.length > 0) {
bulkStatus.innerHTML = '<span class="text-warning" style="font-size:0.85rem">&#9888; Duplikátum: ' + dupWarnings.join(' | ') + '</span>';
} else {
bulkStatus.innerHTML = '';
}
updateRowStatus(idx, 'reviewing');
// Wait for user: approveAndContinue() or skipCurrent()
}
} catch (e) {
updateRowStatus(idx, 'error', e.message);
updateBulkCounter();
processNext();
}
}
function isDuplicateForTarget(scrapeData, target) {
if (target === 'mealie') return !!scrapeData.duplicate;
if (target === 'tandoor') return !!scrapeData.tandoor_duplicate;
if (target === 'both') return !!scrapeData.duplicate && !!scrapeData.tandoor_duplicate;
return false;
}
async function sendForBulk(idx, recipe, mealieDup, tandoorDup) {
updateRowStatus(idx, 'importing');
const target = bulkState.target;
const links = {};
const errors = [];
try {
if ((target === 'mealie' || target === 'both') && !mealieDup) {
const resp = await fetch('/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
const data = await resp.json();
if (data.ok) {
links['Mealie'] = data.url;
} else {
errors.push('Mealie: ' + (data.error || 'ismeretlen hiba'));
}
}
if ((target === 'tandoor' || target === 'both') && !tandoorDup) {
const resp = await fetch('/send-tandoor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipe),
});
const data = await resp.json();
if (data.ok) {
links['Tandoor'] = data.url;
} else {
errors.push('Tandoor: ' + (data.error || 'ismeretlen hiba'));
}
}
if (errors.length > 0 && Object.keys(links).length === 0) {
updateRowStatus(idx, 'error', errors.join('; '));
} else {
updateRowStatus(idx, 'done');
updateRowLinks(idx, links);
bulkState.results[idx].links = links;
}
} catch (e) {
updateRowStatus(idx, 'error', e.message);
}
updateBulkCounter();
processNext();
}
function approveAndContinue() {
const idx = bulkState.currentIndex;
const recipe = gatherRecipe();
if (!recipe.title) { alert('A recept neve kötelező!'); return; }
document.getElementById('previewCard').classList.remove('visible');
document.getElementById('bulkSendStatus').innerHTML = '';
const cached = bulkState.scrapeCache[idx];
sendForBulk(idx, recipe, null, null);
}
function skipCurrent() {
const idx = bulkState.currentIndex;
updateRowStatus(idx, 'skipped');
document.getElementById('previewCard').classList.remove('visible');
document.getElementById('bulkSendStatus').innerHTML = '';
updateBulkCounter();
processNext();
}
function autoImportRemaining() {
// Read tag preference
const autoTags = document.querySelector('input[name="bulkTags"]:checked').value === 'all';
bulkState.autoTags = autoTags;
bulkState.mode = 'auto';
document.getElementById('bulkAutoRemaining').style.display = 'none';
const idx = bulkState.currentIndex;
if (bulkState.results[idx] && bulkState.results[idx].status === 'reviewing') {
// Currently reviewing — gather and send current recipe
const recipe = gatherRecipe();
document.getElementById('previewCard').classList.remove('visible');
document.getElementById('bulkSendStatus').innerHTML = '';
if (recipe.title) {
if (!bulkState.autoTags) recipe.tags = [];
sendForBulk(idx, recipe, null, null);
} else {
updateRowStatus(idx, 'skipped');
updateBulkCounter();
processNext();
}
}
}
function stopBulk() {
bulkState.stopped = true;
document.getElementById('bulkStopBtn').style.display = 'none';
}
function finishBulk() {
bulkState.running = false;
document.getElementById('previewCard').classList.remove('visible');
document.getElementById('bulkStopBtn').style.display = 'none';
document.getElementById('bulkAutoRemaining').style.display = 'none';
showSingleButtons();
// Re-enable start buttons
document.querySelectorAll('#tabBulk .btn-success').forEach(b => b.disabled = false);
// Remove active row highlight
document.querySelectorAll('.bulk-table tr.row-active').forEach(r => r.classList.remove('row-active'));
showBulkSummary();
updateBulkCounter();
}
// =========================================================================
// Utilities
// =========================================================================
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
</script>
{% endblock %}