From 85b470809e530e3cff29fdea6d740c7d2e9614f9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:19:13 -0700 Subject: [PATCH] =?UTF-8?q?Add=20automation=20group=20management=20?= =?UTF-8?q?=E2=80=94=20rename,=20delete,=20bulk=20toggle,=20drag-drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full automation page upgrade with group management and drag-and-drop: Backend: batch_update_group() and bulk_set_enabled() DB methods, new PUT /api/automations/group and POST /api/automations/bulk-toggle endpoints. Group headers: rename (inline edit), delete (choice dialog — keep automations or delete all), bulk toggle (enable/disable all in group). Actions appear on hover, styled as small icon buttons. Drag and drop: non-system cards are draggable between group sections. Drop zones show dashed accent border feedback. Collapsed sections auto-expand on 500ms drag-hover. System/Hub sections dimmed during drag. dragenter counter pattern handles child element bubbling. Delete group dialog: glass card modal with three options — keep automations (move to My Automations), delete everything, or cancel. --- database/music_database.py | 38 +++++ web_server.py | 61 ++++++++ webui/static/script.js | 277 ++++++++++++++++++++++++++++++++++++- webui/static/style.css | 166 ++++++++++++++++++++++ 4 files changed, 538 insertions(+), 4 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 9776de5f..c966b22e 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -10794,6 +10794,44 @@ class MusicDatabase: logger.error(f"Error deleting automation {automation_id}: {e}") return False + def batch_update_group(self, automation_ids: list, group_name: str = None) -> int: + """Batch update group_name for multiple automations. Excludes system automations.""" + if not automation_ids: + return 0 + try: + with self._get_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in automation_ids) + cursor.execute( + f"UPDATE automations SET group_name = ?, updated_at = CURRENT_TIMESTAMP " + f"WHERE id IN ({placeholders}) AND (is_system IS NULL OR is_system = 0)", + [group_name] + list(automation_ids) + ) + conn.commit() + return cursor.rowcount + except Exception as e: + logger.error(f"Error batch updating group: {e}") + return 0 + + def bulk_set_enabled(self, automation_ids: list, enabled: bool) -> int: + """Bulk enable/disable multiple automations. Excludes system automations.""" + if not automation_ids: + return 0 + try: + with self._get_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in automation_ids) + cursor.execute( + f"UPDATE automations SET enabled = ?, updated_at = CURRENT_TIMESTAMP " + f"WHERE id IN ({placeholders}) AND (is_system IS NULL OR is_system = 0)", + [1 if enabled else 0] + list(automation_ids) + ) + conn.commit() + return cursor.rowcount + except Exception as e: + logger.error(f"Error bulk toggling automations: {e}") + return 0 + def toggle_automation(self, automation_id: int) -> bool: """Toggle the enabled state of an automation. Returns True on success.""" try: diff --git a/web_server.py b/web_server.py index 4d46656e..17d80479 100644 --- a/web_server.py +++ b/web_server.py @@ -6103,6 +6103,67 @@ def update_automation_endpoint(automation_id): logger.error(f"Error updating automation: {e}") return jsonify({"error": str(e)}), 500 +@app.route('/api/automations/group', methods=['PUT']) +def batch_update_automation_group(): + """Batch update group_name for multiple automations (rename group, delete group, drag-drop).""" + try: + data = request.get_json() + automation_ids = data.get('automation_ids', []) + group_name = data.get('group_name') # None/null = ungroup + + if not automation_ids or not isinstance(automation_ids, list): + return jsonify({"error": "automation_ids must be a non-empty list"}), 400 + + # Sanitize IDs to integers + try: + automation_ids = [int(aid) for aid in automation_ids] + except (ValueError, TypeError): + return jsonify({"error": "automation_ids must contain integers"}), 400 + + db = get_database() + updated = db.batch_update_group(automation_ids, group_name) + + return jsonify({"success": True, "updated": updated}) + except Exception as e: + logger.error(f"Error batch updating automation group: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/automations/bulk-toggle', methods=['POST']) +def bulk_toggle_automations(): + """Bulk enable/disable multiple automations.""" + try: + data = request.get_json() + automation_ids = data.get('automation_ids', []) + enabled = data.get('enabled', True) + + if not automation_ids or not isinstance(automation_ids, list): + return jsonify({"error": "automation_ids must be a non-empty list"}), 400 + + try: + automation_ids = [int(aid) for aid in automation_ids] + except (ValueError, TypeError): + return jsonify({"error": "automation_ids must contain integers"}), 400 + + db = get_database() + updated = db.bulk_set_enabled(automation_ids, bool(enabled)) + + # Reschedule/cancel affected automations + if automation_engine and updated > 0: + for aid in automation_ids: + auto = db.get_automation(aid) + if auto: + if auto.get('enabled'): + automation_engine.schedule_automation(auto) + else: + automation_engine.cancel_automation(aid) + + return jsonify({"success": True, "updated": updated}) + except Exception as e: + logger.error(f"Error bulk toggling automations: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route('/api/automations/', methods=['DELETE']) def delete_automation_endpoint(automation_id): """Delete an automation. System automations cannot be deleted.""" diff --git a/webui/static/script.js b/webui/static/script.js index ce5062ab..cb24c3c6 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -68895,26 +68895,113 @@ const AUTO_HUB_REFERENCE = { // --- Load & Render List --- -function _buildAutomationSection(id, label, automations, useGrid) { +// Drag-and-drop state +let _autoDragState = null; +let _autoDragEnterCount = 0; +let _autoDragExpandTimer = null; + +function _buildAutomationSection(id, label, automations, useGrid, options = {}) { + const groupName = options.groupName || null; + const isProtected = options.isProtected || false; // System, Hub sections + const section = document.createElement('div'); section.className = 'automations-section'; + if (isProtected) section.classList.add('section-protected'); section.id = id; + if (groupName) section.dataset.groupName = groupName; const collapsed = localStorage.getItem('auto_section_' + id) === '1'; if (collapsed) section.classList.add('collapsed'); + const header = document.createElement('div'); header.className = 'automations-section-header'; + + // Group header actions (rename, bulk toggle, delete) — only for user groups + let actionsHtml = ''; + if (groupName && !isProtected) { + const enabledCount = automations.filter(a => a.enabled).length; + const allEnabled = enabledCount === automations.length; + actionsHtml = ` +
+ + + +
+ `; + } + header.innerHTML = ` ${label} ${automations.length} + ${actionsHtml} `; - header.onclick = () => { + header.onclick = (e) => { + if (e.target.closest('.section-actions')) return; section.classList.toggle('collapsed'); localStorage.setItem('auto_section_' + id, section.classList.contains('collapsed') ? '1' : '0'); }; + const body = document.createElement('div'); body.className = 'automations-section-body'; + + // Drop zone setup (not for protected sections) + if (!isProtected) { + const dropGroupName = groupName; // null for "My Automations" + body.addEventListener('dragover', (e) => { + if (!_autoDragState) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + body.classList.add('drop-target'); + }); + body.addEventListener('dragenter', (e) => { + if (!_autoDragState) return; + _autoDragEnterCount++; + body.classList.add('drop-target'); + // Expand collapsed sections on drag-hover + if (section.classList.contains('collapsed')) { + _autoDragExpandTimer = setTimeout(() => { + section.classList.remove('collapsed'); + }, 500); + } + }); + body.addEventListener('dragleave', (e) => { + if (!_autoDragState) return; + _autoDragEnterCount--; + if (_autoDragEnterCount <= 0) { + _autoDragEnterCount = 0; + body.classList.remove('drop-target'); + if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; } + } + }); + body.addEventListener('drop', async (e) => { + e.preventDefault(); + body.classList.remove('drop-target'); + _autoDragEnterCount = 0; + if (!_autoDragState) return; + const draggedId = _autoDragState.id; + const fromGroup = _autoDragState.groupName; + if (fromGroup === dropGroupName) return; // Same group, no-op + try { + const res = await fetch('/api/automations/' + draggedId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_name: dropGroupName }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(dropGroupName ? `Moved to "${dropGroupName}"` : 'Moved to My Automations', 'success'); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } + }); + } + const container = document.createElement('div'); container.className = useGrid ? 'automations-grid' : 'automations-user-list'; automations.forEach(a => container.appendChild(renderAutomationCard(a))); @@ -68924,6 +69011,167 @@ function _buildAutomationSection(id, label, automations, useGrid) { return section; } +/** + * Delete a group — ungroups all automations (moves to My Automations). + */ +async function _deleteGroup(groupName) { + // Collect automation IDs in this group + const ids = []; + document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => { + if (card.dataset.id) ids.push(parseInt(card.dataset.id)); + }); + + if (ids.length === 0) { await loadAutomations(); return; } + + // Show choice dialog — ungroup or delete all + const choice = await _showDeleteGroupDialog(groupName, ids.length); + if (!choice) return; + + try { + if (choice === 'ungroup') { + const res = await fetch('/api/automations/group', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ automation_ids: ids, group_name: null }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(`Dissolved group "${groupName}" — ${data.updated} automations moved to My Automations`, 'success'); + } else if (choice === 'delete_all') { + // Delete each automation + let deleted = 0; + for (const id of ids) { + try { + const res = await fetch('/api/automations/' + id, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) deleted++; + } catch (e) {} + } + showToast(`Deleted group "${groupName}" and ${deleted} automation${deleted !== 1 ? 's' : ''}`, 'success'); + } + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +function _showDeleteGroupDialog(groupName, count) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + + overlay.innerHTML = ` +
+
🗑️
+

Delete Group "${groupName}"

+

This group contains ${count} automation${count !== 1 ? 's' : ''}. What would you like to do?

+
+ + + +
+
+ `; + + overlay.querySelector('#dg-ungroup').onclick = () => { overlay.remove(); resolve('ungroup'); }; + overlay.querySelector('#dg-delete').onclick = () => { overlay.remove(); resolve('delete_all'); }; + overlay.querySelector('#dg-cancel').onclick = () => { overlay.remove(); resolve(null); }; + + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); } + }); + + document.body.appendChild(overlay); + }); +} + +/** + * Rename a group — inline edit on the section header label. + */ +function _startRenameGroup(groupName, btnEl) { + const section = btnEl.closest('.automations-section'); + const labelEl = section?.querySelector('.section-label'); + if (!labelEl) return; + + const input = document.createElement('input'); + input.className = 'section-rename-input'; + input.value = groupName; + input.onclick = (e) => e.stopPropagation(); + + const originalText = labelEl.textContent; + labelEl.textContent = ''; + labelEl.appendChild(input); + input.focus(); + input.select(); + + const finish = async (save) => { + const newName = input.value.trim(); + input.removeEventListener('blur', blurHandler); + if (!save || !newName || newName === groupName) { + labelEl.textContent = originalText; + return; + } + + const ids = []; + section.querySelectorAll('.automation-card').forEach(card => { + if (card.dataset.id) ids.push(parseInt(card.dataset.id)); + }); + + try { + const res = await fetch('/api/automations/group', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ automation_ids: ids, group_name: newName }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(`Renamed to "${newName}"`, 'success'); + await loadAutomations(); + } catch (err) { + showToast('Error: ' + err.message, 'error'); + labelEl.textContent = originalText; + } + }; + + input.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + if (e.key === 'Escape') { finish(false); } + }); + const blurHandler = () => finish(true); + input.addEventListener('blur', blurHandler); +} + +/** + * Bulk toggle all automations in a group. + */ +async function _bulkToggleGroup(groupName, currentlyAllEnabled) { + const ids = []; + document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => { + if (card.dataset.id) ids.push(parseInt(card.dataset.id)); + }); + if (ids.length === 0) return; + + const targetEnabled = !currentlyAllEnabled; + try { + const res = await fetch('/api/automations/bulk-toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ automation_ids: ids, enabled: targetEnabled }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(`${targetEnabled ? 'Enabled' : 'Disabled'} ${data.updated} automations`, 'success'); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + async function loadAutomations() { const list = document.getElementById('automations-list'); const empty = document.getElementById('automations-empty'); @@ -68945,7 +69193,7 @@ async function loadAutomations() { const userAutos = automations.filter(a => !a.is_system); if (systemAutos.length) { - list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true)); + list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true, { isProtected: true })); } // Automation Hub section @@ -68957,7 +69205,7 @@ async function loadAutomations() { groups.forEach(g => { const groupAutos = userAutos.filter(a => a.group_name === g); if (groupAutos.length) { - list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true)); + list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true, { groupName: g })); } }); if (ungrouped.length) { @@ -69705,6 +69953,27 @@ function renderAutomationCard(a) { card.dataset.id = a.id; card.dataset.triggerType = a.trigger_type || ''; card.dataset.actionType = a.action_type || ''; + + // Drag-and-drop (non-system only) + if (!a.is_system) { + card.draggable = true; + card.addEventListener('dragstart', (e) => { + _autoDragState = { id: a.id, groupName: a.group_name || null }; + e.dataTransfer.setData('text/plain', String(a.id)); + e.dataTransfer.effectAllowed = 'move'; + card.classList.add('dragging'); + // Dim protected sections during drag + document.querySelectorAll('.section-protected').forEach(s => s.classList.add('no-drop')); + }); + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + _autoDragState = null; + _autoDragEnterCount = 0; + document.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target')); + document.querySelectorAll('.no-drop').forEach(el => el.classList.remove('no-drop')); + if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; } + }); + } const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F'; const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F'; const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config); diff --git a/webui/static/style.css b/webui/static/style.css index 62b36a22..8eb00bf2 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -41129,6 +41129,172 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) { opacity: 0; } +/* --- Group header actions --- */ +.section-actions { + display: flex; + gap: 4px; + align-items: center; + opacity: 0; + transition: opacity 0.2s; + margin-left: 4px; +} + +.automations-section-header:hover .section-actions { + opacity: 1; +} + +.section-action-btn { + width: 26px; + height: 26px; + border: none; + background: rgba(255, 255, 255, 0.05); + border-radius: 6px; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + color: rgba(255, 255, 255, 0.5); +} + +.section-action-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.section-action-danger:hover { + background: rgba(239, 68, 68, 0.15); + color: #f87171; +} + +.section-rename-input { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(var(--accent-rgb), 0.4); + border-radius: 4px; + color: #fff; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + padding: 2px 8px; + width: 180px; + outline: none; +} + +.section-rename-input:focus { + border-color: rgba(var(--accent-rgb), 0.6); + box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.15); +} + +/* --- Drag and drop --- */ +.automation-card[draggable="true"] { + cursor: grab; +} + +.automation-card[draggable="true"]:active { + cursor: grabbing; +} + +.automation-card.dragging { + opacity: 0.35; + transform: scale(0.97); + box-shadow: none; +} + +.automations-section-body.drop-target { + background: rgba(var(--accent-rgb), 0.04); + border: 2px dashed rgba(var(--accent-rgb), 0.25); + border-radius: 10px; + min-height: 50px; + transition: background 0.2s, border-color 0.2s; +} + +.section-protected.no-drop { + opacity: 0.4; + pointer-events: none; +} + +/* --- Delete group dialog --- */ +.delete-group-dialog { + background: linear-gradient(135deg, rgba(26, 26, 26, 0.98) 0%, rgba(16, 16, 16, 0.99) 100%); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 28px 24px; + max-width: 420px; + width: 90vw; + text-align: center; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.delete-group-icon { + font-size: 32px; + margin-bottom: 8px; +} + +.delete-group-title { + font-size: 17px; + font-weight: 700; + color: #fff; + margin: 0 0 8px 0; +} + +.delete-group-message { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + margin: 0 0 18px 0; + line-height: 1.5; +} + +.delete-group-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.delete-group-btn { + padding: 10px 16px; + border-radius: 8px; + border: 1px solid transparent; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.delete-group-keep { + background: rgba(var(--accent-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); + border-color: rgba(var(--accent-rgb), 0.25); +} + +.delete-group-keep:hover { + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.4); +} + +.delete-group-remove { + background: rgba(239, 68, 68, 0.12); + color: #f87171; + border-color: rgba(239, 68, 68, 0.25); +} + +.delete-group-remove:hover { + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.4); +} + +.delete-group-cancel { + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); + border-color: rgba(255, 255, 255, 0.06); +} + +.delete-group-cancel:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.7); +} + /* --- System Automations 2-Column Grid --- */ .automations-grid { display: grid;