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>
This commit is contained in:
+3
-2
@@ -6,7 +6,7 @@ import traceback
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
|
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
from app.scraper import scrape
|
from app.scraper import scrape, supported_sites
|
||||||
from app.mealie import MealieClient
|
from app.mealie import MealieClient
|
||||||
from app.tandoor import TandoorClient
|
from app.tandoor import TandoorClient
|
||||||
|
|
||||||
@@ -93,7 +93,8 @@ def import_page():
|
|||||||
flash("Először állíts be legalább egy szolgáltatást (Mealie vagy Tandoor).", "warning")
|
flash("Először állíts be legalább egy szolgáltatást (Mealie vagy Tandoor).", "warning")
|
||||||
return redirect(url_for("settings"))
|
return redirect(url_for("settings"))
|
||||||
return render_template("import.html", cfg=cfg, version=VERSION,
|
return render_template("import.html", cfg=cfg, version=VERSION,
|
||||||
has_mealie=has_mealie, has_tandoor=has_tandoor)
|
has_mealie=has_mealie, has_tandoor=has_tandoor,
|
||||||
|
supported_sites=supported_sites())
|
||||||
|
|
||||||
|
|
||||||
@app.route("/scrape", methods=["POST"])
|
@app.route("/scrape", methods=["POST"])
|
||||||
|
|||||||
+642
-22
@@ -198,13 +198,136 @@
|
|||||||
|
|
||||||
.result-card { display: none; }
|
.result-card { display: none; }
|
||||||
.result-card.visible { display: block; }
|
.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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Step 1: URL input -->
|
<!-- Step 1: URL input -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Recept importálása</h2>
|
<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">
|
<div class="flex">
|
||||||
<input type="url" id="recipeUrl" class="grow" style="margin-bottom:0"
|
<input type="url" id="recipeUrl" class="grow" style="margin-bottom:0"
|
||||||
placeholder="https://www.mindmegette.hu/recept/brassoi">
|
placeholder="https://www.mindmegette.hu/recept/brassoi">
|
||||||
@@ -215,6 +338,77 @@
|
|||||||
<div id="scrapeStatus" class="mt-1"></div>
|
<div id="scrapeStatus" class="mt-1"></div>
|
||||||
</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 https://streetkitchen.hu/receptek/... 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 -->
|
<!-- Step 2: Editable preview -->
|
||||||
<div class="recipe-preview" id="previewCard">
|
<div class="recipe-preview" id="previewCard">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -272,7 +466,8 @@
|
|||||||
|
|
||||||
<hr class="section-divider">
|
<hr class="section-divider">
|
||||||
|
|
||||||
<div class="flex mt-2">
|
<!-- Single mode action buttons -->
|
||||||
|
<div class="flex mt-2" id="singleActions">
|
||||||
{% if has_mealie %}
|
{% if has_mealie %}
|
||||||
<button class="btn btn-success" id="sendMealieBtn" onclick="sendToMealie()">
|
<button class="btn btn-success" id="sendMealieBtn" onclick="sendToMealie()">
|
||||||
Importálás Mealie-be
|
Importálás Mealie-be
|
||||||
@@ -285,10 +480,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<span id="sendStatus"></span>
|
<span id="sendStatus"></span>
|
||||||
</div>
|
</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 →
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="skipCurrent()">
|
||||||
|
Kihagyás →
|
||||||
|
</button>
|
||||||
|
<span id="bulkSendStatus"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 3: Result -->
|
<!-- Step 3: Result (single mode) -->
|
||||||
<div class="result-card" id="resultCard">
|
<div class="result-card" id="resultCard">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="text-success">Recept sikeresen importálva!</h2>
|
<h2 class="text-success">Recept sikeresen importálva!</h2>
|
||||||
@@ -301,6 +507,30 @@
|
|||||||
<script>
|
<script>
|
||||||
let currentRecipe = null;
|
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() {
|
async function scrapeRecipe() {
|
||||||
const url = document.getElementById('recipeUrl').value.trim();
|
const url = document.getElementById('recipeUrl').value.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -328,6 +558,7 @@ async function scrapeRecipe() {
|
|||||||
|
|
||||||
currentRecipe = data.data;
|
currentRecipe = data.data;
|
||||||
populatePreview(currentRecipe);
|
populatePreview(currentRecipe);
|
||||||
|
showSingleButtons();
|
||||||
document.getElementById('previewCard').classList.add('visible');
|
document.getElementById('previewCard').classList.add('visible');
|
||||||
|
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
@@ -340,9 +571,9 @@ async function scrapeRecipe() {
|
|||||||
+ escHtml(data.tandoor_duplicate.name) + '</a>');
|
+ escHtml(data.tandoor_duplicate.name) + '</a>');
|
||||||
}
|
}
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
status.innerHTML = '<span class="text-warning">⚠ Ez a recept már létezik: ' + warnings.join(' | ') + '</span>';
|
status.innerHTML = '<span class="text-warning">⚠ Ez a recept már létezik: ' + warnings.join(' | ') + '</span>';
|
||||||
} else {
|
} else {
|
||||||
status.innerHTML = '<span class="text-success">✓ Beolvasva</span>';
|
status.innerHTML = '<span class="text-success">✓ Beolvasva</span>';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
|
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
|
||||||
@@ -392,7 +623,7 @@ function addIngredient(item) {
|
|||||||
+ '<input type="text" class="ing-unit" placeholder="" value="' + escHtml(item.unit || '') + '">'
|
+ '<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-food" placeholder="Hozzávaló" value="' + escHtml(item.food || '') + '">'
|
||||||
+ '<input type="text" class="ing-extra" placeholder="" value="' + escHtml(item.extra || '') + '">'
|
+ '<input type="text" class="ing-extra" placeholder="" value="' + escHtml(item.extra || '') + '">'
|
||||||
+ '<button onclick="this.parentElement.remove()">✕</button>';
|
+ '<button onclick="this.parentElement.remove()">✕</button>';
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +632,7 @@ function addIngredientGroup(name) {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ingredient-group';
|
row.className = 'ingredient-group';
|
||||||
row.innerHTML = '<input type="text" class="ing-group-name" placeholder="Csoport neve" value="' + escHtml(name || '') + '">'
|
row.innerHTML = '<input type="text" class="ing-group-name" placeholder="Csoport neve" value="' + escHtml(name || '') + '">'
|
||||||
+ '<button onclick="this.parentElement.remove()">✕</button>';
|
+ '<button onclick="this.parentElement.remove()">✕</button>';
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +643,7 @@ function addInstruction(value) {
|
|||||||
row.className = 'instruction-row';
|
row.className = 'instruction-row';
|
||||||
row.innerHTML = '<span class="step-num">' + idx + '</span>'
|
row.innerHTML = '<span class="step-num">' + idx + '</span>'
|
||||||
+ '<textarea>' + escHtml(value) + '</textarea>'
|
+ '<textarea>' + escHtml(value) + '</textarea>'
|
||||||
+ '<button onclick="removeInstruction(this)">✕</button>';
|
+ '<button onclick="removeInstruction(this)">✕</button>';
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +722,7 @@ async function sendToMealie() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
status.innerHTML = '<span class="text-success">✓ Mealie kész</span>';
|
status.innerHTML = '<span class="text-success">✓ Mealie kész</span>';
|
||||||
importedLinks['Mealie'] = data.url;
|
importedLinks['Mealie'] = data.url;
|
||||||
showResultCard();
|
showResultCard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -523,7 +754,7 @@ async function sendToTandoor() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
status.innerHTML = '<span class="text-success">✓ Tandoor kész</span>';
|
status.innerHTML = '<span class="text-success">✓ Tandoor kész</span>';
|
||||||
importedLinks['Tandoor'] = data.url;
|
importedLinks['Tandoor'] = data.url;
|
||||||
showResultCard();
|
showResultCard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -534,13 +765,16 @@ async function sendToTandoor() {
|
|||||||
|
|
||||||
function showResultCard() {
|
function showResultCard() {
|
||||||
const links = Object.entries(importedLinks).map(([name, url]) =>
|
const links = Object.entries(importedLinks).map(([name, url]) =>
|
||||||
'<a href="' + escHtml(url) + '" target="_blank" style="color:var(--accent);">Megnyitás ' + name + '-ben →</a>'
|
'<a href="' + escHtml(url) + '" target="_blank" style="color:var(--accent);">Megnyitás ' + name + '-ben →</a>'
|
||||||
).join('<br>');
|
).join('<br>');
|
||||||
document.getElementById('resultLinks').innerHTML = links;
|
document.getElementById('resultLinks').innerHTML = links;
|
||||||
document.getElementById('resultCard').classList.add('visible');
|
document.getElementById('resultCard').classList.add('visible');
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tag management ---
|
// =========================================================================
|
||||||
|
// Tag management
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
let existingTags = { mealie: [], tandoor: [] };
|
let existingTags = { mealie: [], tandoor: [] };
|
||||||
|
|
||||||
async function loadExistingTags() {
|
async function loadExistingTags() {
|
||||||
@@ -561,7 +795,6 @@ function tagExistsIn(name, containerId) {
|
|||||||
function addTagChip(name, active) {
|
function addTagChip(name, active) {
|
||||||
name = name.trim();
|
name = name.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
// Avoid duplicates in both areas
|
|
||||||
if (tagExistsIn(name, 'tagsActive') || tagExistsIn(name, 'tagsInactive')) return;
|
if (tagExistsIn(name, 'tagsActive') || tagExistsIn(name, 'tagsInactive')) return;
|
||||||
|
|
||||||
const chip = document.createElement('span');
|
const chip = document.createElement('span');
|
||||||
@@ -580,12 +813,10 @@ function addTagChip(name, active) {
|
|||||||
|
|
||||||
function toggleTag(chip) {
|
function toggleTag(chip) {
|
||||||
if (chip.classList.contains('tag-active')) {
|
if (chip.classList.contains('tag-active')) {
|
||||||
// Move to inactive
|
|
||||||
chip.classList.remove('tag-active');
|
chip.classList.remove('tag-active');
|
||||||
chip.classList.add('tag-inactive');
|
chip.classList.add('tag-inactive');
|
||||||
document.getElementById('tagsInactive').appendChild(chip);
|
document.getElementById('tagsInactive').appendChild(chip);
|
||||||
} else {
|
} else {
|
||||||
// Move to active
|
|
||||||
chip.classList.remove('tag-inactive');
|
chip.classList.remove('tag-inactive');
|
||||||
chip.classList.add('tag-active');
|
chip.classList.add('tag-active');
|
||||||
document.getElementById('tagsActive').appendChild(chip);
|
document.getElementById('tagsActive').appendChild(chip);
|
||||||
@@ -608,7 +839,6 @@ function onTagSearch() {
|
|||||||
if (!q) { dropdown.classList.remove('open'); return; }
|
if (!q) { dropdown.classList.remove('open'); return; }
|
||||||
|
|
||||||
const allNames = getAllTagNames();
|
const allNames = getAllTagNames();
|
||||||
// Merge tags from both sources, track origin
|
|
||||||
const seen = {};
|
const seen = {};
|
||||||
for (const t of existingTags.mealie || []) {
|
for (const t of existingTags.mealie || []) {
|
||||||
const k = t.toLowerCase();
|
const k = t.toLowerCase();
|
||||||
@@ -621,7 +851,6 @@ function onTagSearch() {
|
|||||||
seen[k].sources.push('T');
|
seen[k].sources.push('T');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by query, exclude already-present tags
|
|
||||||
const matches = Object.values(seen)
|
const matches = Object.values(seen)
|
||||||
.filter(e => e.name.toLowerCase().includes(q) && !allNames.includes(e.name.toLowerCase()))
|
.filter(e => e.name.toLowerCase().includes(q) && !allNames.includes(e.name.toLowerCase()))
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
@@ -634,7 +863,6 @@ function onTagSearch() {
|
|||||||
+ '<span class="tag-source">' + src + '</span></div>';
|
+ '<span class="tag-source">' + src + '</span></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Add new" option if exact match not found
|
|
||||||
const exactExists = matches.some(m => m.name.toLowerCase() === q) || allNames.includes(q);
|
const exactExists = matches.some(m => m.name.toLowerCase() === q) || allNames.includes(q);
|
||||||
if (!exactExists && q) {
|
if (!exactExists && q) {
|
||||||
html += '<div class="tag-dropdown-item tag-add-new" onclick="selectTag(\'' + escHtml(input.value.trim()).replace(/'/g, "\\'") + '\')">'
|
html += '<div class="tag-dropdown-item tag-add-new" onclick="selectTag(\'' + escHtml(input.value.trim()).replace(/'/g, "\\'") + '\')">'
|
||||||
@@ -646,7 +874,7 @@ function onTagSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectTag(name) {
|
function selectTag(name) {
|
||||||
addTagChip(name, true); // manually added → active
|
addTagChip(name, true);
|
||||||
const input = document.getElementById('tagSearch');
|
const input = document.getElementById('tagSearch');
|
||||||
input.value = '';
|
input.value = '';
|
||||||
document.getElementById('tagDropdown').classList.remove('open');
|
document.getElementById('tagDropdown').classList.remove('open');
|
||||||
@@ -658,7 +886,7 @@ function onTagKeydown(e) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const val = e.target.value.trim();
|
const val = e.target.value.trim();
|
||||||
if (val) {
|
if (val) {
|
||||||
addTagChip(val, true); // manually added → active
|
addTagChip(val, true);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
document.getElementById('tagDropdown').classList.remove('open');
|
document.getElementById('tagDropdown').classList.remove('open');
|
||||||
}
|
}
|
||||||
@@ -667,16 +895,408 @@ function onTagKeydown(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (!e.target.closest('.tag-search-wrap')) {
|
if (!e.target.closest('.tag-search-wrap')) {
|
||||||
document.getElementById('tagDropdown').classList.remove('open');
|
document.getElementById('tagDropdown').classList.remove('open');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load existing tags on page load
|
|
||||||
loadExistingTags();
|
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 — ';
|
||||||
|
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">⚠ 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) {
|
function escHtml(s) {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.textContent = s;
|
d.textContent = s;
|
||||||
|
|||||||
Reference in New Issue
Block a user