allow multiple notification calls per automation as well as a new signal fire utility
This commit is contained in:
parent
dd5f2f07e9
commit
8b6a2c0adc
5 changed files with 530 additions and 82 deletions
|
|
@ -1,17 +1,19 @@
|
|||
"""
|
||||
Automation Engine — trigger → action → notify scheduler for SoulSync.
|
||||
Automation Engine — trigger → action → then scheduler for SoulSync.
|
||||
|
||||
Architecture:
|
||||
- Triggers (WHEN): schedule timer, event-based (track_downloaded, batch_complete, etc.)
|
||||
- Triggers (WHEN): schedule timer, event-based, signal-based (signal_received)
|
||||
- Actions (DO): real SoulSync operations registered by web_server.py
|
||||
- Notifications (NOTIFY): optional Discord webhook with template variables
|
||||
- Then (THEN): 1–3 post-action steps — notifications (Discord/Pushbullet/Telegram) and/or fire_signal
|
||||
- Conditions: optional filters on event data (artist contains, title equals, etc.)
|
||||
- Signals: user-named events that chain automations together (fire_signal → signal_received)
|
||||
|
||||
Uses threading.Timer pattern for schedule triggers.
|
||||
Event triggers react to emit() calls from web_server.py hook points.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
|
|
@ -57,6 +59,11 @@ class AutomationEngine:
|
|||
self._event_automations = {}
|
||||
self._event_cache_dirty = True
|
||||
|
||||
# Signal safety: cooldown tracking and chain depth limit
|
||||
self._signal_cooldowns = {} # signal event key → last fire timestamp
|
||||
self._max_chain_depth = 5
|
||||
self._signal_cooldown_seconds = 10
|
||||
|
||||
# Trigger registry: type → setup function (schedule only — events use emit())
|
||||
self._trigger_handlers = {
|
||||
'schedule': self._setup_schedule_trigger,
|
||||
|
|
@ -82,6 +89,16 @@ class AutomationEngine:
|
|||
self._progress_init_fn = init_fn
|
||||
self._progress_finish_fn = finish_fn
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_signal_name(name):
|
||||
"""Sanitize signal name: lowercase, alphanumeric + underscore/hyphen, max 50 chars."""
|
||||
if not name:
|
||||
return ''
|
||||
name = name.lower().strip()
|
||||
name = re.sub(r'[^a-z0-9_\-]', '_', name)
|
||||
name = re.sub(r'_+', '_', name).strip('_')
|
||||
return name[:50]
|
||||
|
||||
# --- System Automations ---
|
||||
|
||||
def ensure_system_automations(self):
|
||||
|
|
@ -202,6 +219,20 @@ class AutomationEngine:
|
|||
def _process_event(self, event_type, data):
|
||||
"""Find matching automations and run them."""
|
||||
try:
|
||||
# Signal safety: chain depth limit and cooldown
|
||||
if event_type.startswith('signal:'):
|
||||
depth = data.get('_chain_depth', 0)
|
||||
if depth >= self._max_chain_depth:
|
||||
logger.warning(f"Signal chain depth limit ({self._max_chain_depth}) reached for {event_type}, stopping")
|
||||
return
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
last = self._signal_cooldowns.get(event_type, 0)
|
||||
if now - last < self._signal_cooldown_seconds:
|
||||
logger.info(f"Signal {event_type} on cooldown ({self._signal_cooldown_seconds}s), skipping")
|
||||
return
|
||||
self._signal_cooldowns[event_type] = now
|
||||
|
||||
if self._event_cache_dirty:
|
||||
self._rebuild_event_cache()
|
||||
|
||||
|
|
@ -234,17 +265,29 @@ class AutomationEngine:
|
|||
|
||||
def _rebuild_event_cache(self):
|
||||
"""Cache which automations listen to which event types."""
|
||||
self._event_automations = {}
|
||||
new_cache = {}
|
||||
try:
|
||||
all_autos = self.db.get_automations()
|
||||
for auto in all_autos:
|
||||
if not auto.get('enabled'):
|
||||
continue
|
||||
tt = auto.get('trigger_type', '')
|
||||
if tt and tt not in self._trigger_handlers:
|
||||
self._event_automations.setdefault(tt, []).append(auto['id'])
|
||||
if tt == 'signal_received':
|
||||
# Signal triggers map to 'signal:{name}' event key
|
||||
try:
|
||||
tc = json.loads(auto.get('trigger_config') or '{}')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tc = {}
|
||||
sig = tc.get('signal_name', '')
|
||||
if sig:
|
||||
key = 'signal:' + self._sanitize_signal_name(sig)
|
||||
new_cache.setdefault(key, []).append(auto['id'])
|
||||
elif tt and tt not in self._trigger_handlers:
|
||||
new_cache.setdefault(tt, []).append(auto['id'])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to rebuild event cache: {e}")
|
||||
# Atomic swap — safe for concurrent readers
|
||||
self._event_automations = new_cache
|
||||
self._event_cache_dirty = False
|
||||
logger.debug(f"Event cache rebuilt: {dict((k, len(v)) for k, v in self._event_automations.items())}")
|
||||
|
||||
|
|
@ -328,16 +371,17 @@ class AutomationEngine:
|
|||
try: self._progress_finish_fn(automation_id, result)
|
||||
except Exception: pass
|
||||
|
||||
# Merge event data into result for notification variables
|
||||
# Merge event data into result for then-action variables
|
||||
merged = {**event_data, **result}
|
||||
chain_depth = event_data.get('_chain_depth', 0)
|
||||
|
||||
try:
|
||||
self._send_notification(auto, merged)
|
||||
self._execute_then_actions(auto, merged, chain_depth)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification failed for event automation {automation_id}: {e}")
|
||||
logger.error(f"Then-actions failed for event automation {automation_id}: {e}")
|
||||
|
||||
# Update run stats (no reschedule — event triggers don't use timers)
|
||||
last_result = json.dumps(merged)
|
||||
last_result = json.dumps({k: v for k, v in merged.items() if not k.startswith('_')})
|
||||
error = result.get('error') if result.get('status') == 'error' else None
|
||||
self.db.update_automation_run(automation_id, error=error, last_result=last_result)
|
||||
|
||||
|
|
@ -358,9 +402,9 @@ class AutomationEngine:
|
|||
if action_type == 'notify_only':
|
||||
result = {'status': 'triggered'}
|
||||
try:
|
||||
self._send_notification(auto, result)
|
||||
self._execute_then_actions(auto, result, chain_depth=0)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification failed for automation {automation_id}: {e}")
|
||||
logger.error(f"Then-actions failed for automation {automation_id}: {e}")
|
||||
self._finish_run(auto, automation_id, result, error=None)
|
||||
return
|
||||
|
||||
|
|
@ -416,11 +460,11 @@ class AutomationEngine:
|
|||
try: self._progress_finish_fn(automation_id, result)
|
||||
except Exception: pass
|
||||
|
||||
# Send notification if configured
|
||||
# Execute then-actions (notifications + fire_signal)
|
||||
try:
|
||||
self._send_notification(auto, result)
|
||||
self._execute_then_actions(auto, result, chain_depth=0)
|
||||
except Exception as e:
|
||||
logger.error(f"Notification failed for automation {automation_id}: {e}")
|
||||
logger.error(f"Then-actions failed for automation {automation_id}: {e}")
|
||||
|
||||
self._finish_run(auto, automation_id, result, error)
|
||||
|
||||
|
|
@ -576,18 +620,28 @@ class AutomationEngine:
|
|||
# Fallback: tomorrow (shouldn't happen with 8-day scan)
|
||||
return now.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=1)
|
||||
|
||||
# --- Notification ---
|
||||
|
||||
def _send_notification(self, automation, action_result):
|
||||
"""Send notification after action completes, if configured."""
|
||||
notify_type = automation.get('notify_type')
|
||||
if not notify_type:
|
||||
return
|
||||
# --- Then Actions (notifications + signals) ---
|
||||
|
||||
def _execute_then_actions(self, automation, action_result, chain_depth=0):
|
||||
"""Execute all THEN actions: notifications (Discord/Pushbullet/Telegram) and fire_signal."""
|
||||
# Read then_actions array
|
||||
try:
|
||||
config = json.loads(automation.get('notify_config') or '{}')
|
||||
except json.JSONDecodeError:
|
||||
config = {}
|
||||
then_actions = json.loads(automation.get('then_actions') or '[]')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
then_actions = []
|
||||
|
||||
# Backward compat: fall back to notify_type/notify_config
|
||||
if not then_actions:
|
||||
nt = automation.get('notify_type')
|
||||
if nt:
|
||||
try:
|
||||
nc = json.loads(automation.get('notify_config') or '{}')
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
nc = {}
|
||||
then_actions = [{'type': nt, 'config': nc}]
|
||||
|
||||
if not then_actions:
|
||||
return
|
||||
|
||||
# Build template variables
|
||||
variables = {
|
||||
|
|
@ -596,16 +650,91 @@ class AutomationEngine:
|
|||
'run_count': str(automation.get('run_count', 0) + 1),
|
||||
'status': action_result.get('status', 'unknown'),
|
||||
}
|
||||
# Add all action result fields as variables
|
||||
for k, v in action_result.items():
|
||||
variables[k] = str(v)
|
||||
if not k.startswith('_'):
|
||||
variables[k] = str(v)
|
||||
|
||||
if notify_type == 'discord_webhook':
|
||||
self._send_discord_notification(config, variables)
|
||||
elif notify_type == 'pushbullet':
|
||||
self._send_pushbullet_notification(config, variables)
|
||||
elif notify_type == 'telegram':
|
||||
self._send_telegram_notification(config, variables)
|
||||
for item in then_actions:
|
||||
try:
|
||||
t = item.get('type', '')
|
||||
c = item.get('config', {})
|
||||
if t == 'discord_webhook':
|
||||
self._send_discord_notification(c, variables)
|
||||
elif t == 'pushbullet':
|
||||
self._send_pushbullet_notification(c, variables)
|
||||
elif t == 'telegram':
|
||||
self._send_telegram_notification(c, variables)
|
||||
elif t == 'fire_signal':
|
||||
sig = self._sanitize_signal_name(c.get('signal_name', ''))
|
||||
if sig:
|
||||
emit_data = {k: v for k, v in action_result.items() if not k.startswith('_')}
|
||||
emit_data['_chain_depth'] = chain_depth + 1
|
||||
emit_data['signal_name'] = sig
|
||||
logger.info(f"Automation '{automation.get('name')}' firing signal: {sig} (depth={chain_depth + 1})")
|
||||
self.emit('signal:' + sig, emit_data)
|
||||
except Exception as e:
|
||||
logger.error(f"Then-action '{item.get('type')}' failed for automation {automation.get('id')}: {e}")
|
||||
|
||||
# --- Signal Cycle Detection ---
|
||||
|
||||
def detect_signal_cycles(self, automations_list):
|
||||
"""Build signal dependency graph from automations list, return cycle path or None.
|
||||
Used by web_server.py to validate before saving an automation."""
|
||||
# Build graph: signal listened → set of signals fired
|
||||
graph = {}
|
||||
for auto in automations_list:
|
||||
if not auto.get('enabled', True):
|
||||
continue
|
||||
tt = auto.get('trigger_type', '')
|
||||
if tt != 'signal_received':
|
||||
continue
|
||||
tc = auto.get('trigger_config') or {}
|
||||
if isinstance(tc, str):
|
||||
try:
|
||||
tc = json.loads(tc)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
tc = {}
|
||||
listen_sig = self._sanitize_signal_name(tc.get('signal_name', ''))
|
||||
if not listen_sig:
|
||||
continue
|
||||
# What signals does this automation fire?
|
||||
ta = auto.get('then_actions') or '[]'
|
||||
if isinstance(ta, str):
|
||||
try:
|
||||
ta = json.loads(ta)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
ta = []
|
||||
for item in ta:
|
||||
if item.get('type') == 'fire_signal':
|
||||
fire_sig = self._sanitize_signal_name(item.get('config', {}).get('signal_name', ''))
|
||||
if fire_sig:
|
||||
graph.setdefault(listen_sig, set()).add(fire_sig)
|
||||
|
||||
# DFS cycle detection with ordered path for readable error messages
|
||||
def has_cycle(node, visited, path_list, path_set):
|
||||
if node in path_set:
|
||||
# Extract the cycle portion from path_list
|
||||
cycle_start = path_list.index(node)
|
||||
return path_list[cycle_start:] + [node]
|
||||
if node in visited:
|
||||
return None
|
||||
visited.add(node)
|
||||
path_list.append(node)
|
||||
path_set.add(node)
|
||||
for neighbor in graph.get(node, []):
|
||||
result = has_cycle(neighbor, visited, path_list, path_set)
|
||||
if result:
|
||||
return result
|
||||
path_list.pop()
|
||||
path_set.discard(node)
|
||||
return None
|
||||
|
||||
visited = set()
|
||||
for start in graph:
|
||||
cycle = has_cycle(start, visited, [], set())
|
||||
if cycle:
|
||||
return cycle
|
||||
return None
|
||||
|
||||
def _send_discord_notification(self, config, variables):
|
||||
"""POST to Discord webhook with template variable substitution."""
|
||||
|
|
|
|||
|
|
@ -400,6 +400,7 @@ class MusicDatabase:
|
|||
# Add notification columns to automations (migration)
|
||||
self._add_automation_notify_columns(cursor)
|
||||
self._add_automation_system_column(cursor)
|
||||
self._add_automation_then_actions_column(cursor)
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
|
@ -431,6 +432,27 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error(f"Error adding automation system column: {e}")
|
||||
|
||||
def _add_automation_then_actions_column(self, cursor):
|
||||
"""Add then_actions column to automations table and migrate existing notify data."""
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(automations)")
|
||||
cols = [c[1] for c in cursor.fetchall()]
|
||||
if 'then_actions' not in cols:
|
||||
cursor.execute("ALTER TABLE automations ADD COLUMN then_actions TEXT DEFAULT '[]'")
|
||||
logger.info("Added then_actions column to automations table")
|
||||
# Migrate existing notify_type/notify_config into then_actions
|
||||
cursor.execute("SELECT id, notify_type, notify_config FROM automations WHERE notify_type IS NOT NULL AND notify_type != ''")
|
||||
for row in cursor.fetchall():
|
||||
try:
|
||||
config = json.loads(row[2]) if row[2] else {}
|
||||
then_actions = json.dumps([{'type': row[1], 'config': config}])
|
||||
cursor.execute("UPDATE automations SET then_actions = ? WHERE id = ?", (then_actions, row[0]))
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Migrated existing notify data to then_actions")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding automation then_actions column: {e}")
|
||||
|
||||
def _add_server_source_columns(self, cursor):
|
||||
"""Add server_source columns to existing tables for multi-server support"""
|
||||
try:
|
||||
|
|
@ -6803,15 +6825,16 @@ class MusicDatabase:
|
|||
|
||||
def create_automation(self, name: str, trigger_type: str, trigger_config: str,
|
||||
action_type: str, action_config: str, profile_id: int = 1,
|
||||
notify_type: str = None, notify_config: str = '{}'):
|
||||
notify_type: str = None, notify_config: str = '{}',
|
||||
then_actions: str = '[]'):
|
||||
"""Create a new automation. Returns the new automation ID or None."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config))
|
||||
INSERT INTO automations (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions))
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
except Exception as e:
|
||||
|
|
@ -6858,7 +6881,7 @@ class MusicDatabase:
|
|||
|
||||
def update_automation(self, automation_id: int, **kwargs) -> bool:
|
||||
"""Update automation fields."""
|
||||
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system'}
|
||||
allowed = {'name', 'enabled', 'trigger_type', 'trigger_config', 'action_type', 'action_config', 'next_run', 'notify_type', 'notify_config', 'last_result', 'is_system', 'then_actions'}
|
||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||
if not updates:
|
||||
return False
|
||||
|
|
|
|||
127
web_server.py
127
web_server.py
|
|
@ -3580,6 +3580,14 @@ def list_automations():
|
|||
auto[field] = {}
|
||||
else:
|
||||
auto[field] = None
|
||||
# Parse then_actions
|
||||
try:
|
||||
auto['then_actions'] = json.loads(auto.get('then_actions') or '[]') if isinstance(auto.get('then_actions'), str) else (auto.get('then_actions') or [])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
auto['then_actions'] = []
|
||||
# Backward compat: if then_actions empty but notify_type set, build it
|
||||
if not auto['then_actions'] and auto.get('notify_type'):
|
||||
auto['then_actions'] = [{'type': auto['notify_type'], 'config': auto.get('notify_config', {})}]
|
||||
return jsonify(automations)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing automations: {e}")
|
||||
|
|
@ -3598,12 +3606,35 @@ def create_automation():
|
|||
trigger_config = json.dumps(data.get('trigger_config', {}))
|
||||
action_type = data.get('action_type', 'process_wishlist')
|
||||
action_config = json.dumps(data.get('action_config', {}))
|
||||
notify_type = data.get('notify_type') or None
|
||||
notify_config = json.dumps(data.get('notify_config', {})) if notify_type else '{}'
|
||||
# then_actions array (new multi-then system)
|
||||
then_actions = data.get('then_actions', [])
|
||||
then_actions_json = json.dumps(then_actions)
|
||||
# Backward compat: derive notify_type/notify_config from first then_action
|
||||
if then_actions:
|
||||
notify_type = then_actions[0].get('type')
|
||||
notify_config = json.dumps(then_actions[0].get('config', {}))
|
||||
else:
|
||||
notify_type = data.get('notify_type') or None
|
||||
notify_config = json.dumps(data.get('notify_config', {})) if notify_type else '{}'
|
||||
profile_id = session.get('profile_id', 1)
|
||||
|
||||
# Signal cycle detection
|
||||
if automation_engine and (trigger_type == 'signal_received' or any(t.get('type') == 'fire_signal' for t in then_actions)):
|
||||
db = get_database()
|
||||
all_autos = db.get_automations(profile_id)
|
||||
test_auto = {
|
||||
'trigger_type': trigger_type,
|
||||
'trigger_config': trigger_config,
|
||||
'then_actions': then_actions_json,
|
||||
'enabled': True,
|
||||
}
|
||||
all_autos.append(test_auto)
|
||||
cycle = automation_engine.detect_signal_cycles(all_autos)
|
||||
if cycle:
|
||||
return jsonify({"error": f"Signal cycle detected: {' → '.join(cycle)}. This would cause an infinite loop."}), 400
|
||||
|
||||
db = get_database()
|
||||
auto_id = db.create_automation(name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config)
|
||||
auto_id = db.create_automation(name, trigger_type, trigger_config, action_type, action_config, profile_id, notify_type, notify_config, then_actions_json)
|
||||
if auto_id is None:
|
||||
return jsonify({"error": "Failed to create automation"}), 500
|
||||
|
||||
|
|
@ -3632,6 +3663,13 @@ def get_automation(automation_id):
|
|||
auto[field] = {}
|
||||
else:
|
||||
auto[field] = None
|
||||
# Parse then_actions
|
||||
try:
|
||||
auto['then_actions'] = json.loads(auto.get('then_actions') or '[]') if isinstance(auto.get('then_actions'), str) else (auto.get('then_actions') or [])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
auto['then_actions'] = []
|
||||
if not auto['then_actions'] and auto.get('notify_type'):
|
||||
auto['then_actions'] = [{'type': auto['notify_type'], 'config': auto.get('notify_config', {})}]
|
||||
return jsonify(auto)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting automation: {e}")
|
||||
|
|
@ -3655,14 +3693,48 @@ def update_automation_endpoint(automation_id):
|
|||
update_fields['action_type'] = data['action_type']
|
||||
if 'action_config' in data:
|
||||
update_fields['action_config'] = json.dumps(data['action_config'])
|
||||
if 'notify_type' in data:
|
||||
if 'then_actions' in data:
|
||||
then_actions = data['then_actions']
|
||||
update_fields['then_actions'] = json.dumps(then_actions)
|
||||
# Backward compat: derive notify_type/notify_config from first then_action
|
||||
if then_actions:
|
||||
update_fields['notify_type'] = then_actions[0].get('type')
|
||||
update_fields['notify_config'] = json.dumps(then_actions[0].get('config', {}))
|
||||
else:
|
||||
update_fields['notify_type'] = None
|
||||
update_fields['notify_config'] = '{}'
|
||||
elif 'notify_type' in data:
|
||||
update_fields['notify_type'] = data['notify_type'] or None
|
||||
if 'notify_config' in data:
|
||||
if 'notify_config' in data and 'then_actions' not in data:
|
||||
update_fields['notify_config'] = json.dumps(data['notify_config'])
|
||||
|
||||
if not update_fields:
|
||||
return jsonify({"error": "No fields to update"}), 400
|
||||
|
||||
# Signal cycle detection
|
||||
trigger_type = data.get('trigger_type', '')
|
||||
then_actions = data.get('then_actions', [])
|
||||
if automation_engine and (trigger_type == 'signal_received' or any(t.get('type') == 'fire_signal' for t in then_actions)):
|
||||
all_autos = db.get_automations()
|
||||
# Replace the automation being edited with the updated version
|
||||
test_autos = []
|
||||
for a in all_autos:
|
||||
if a['id'] == automation_id:
|
||||
merged = dict(a)
|
||||
if 'trigger_type' in data:
|
||||
merged['trigger_type'] = data['trigger_type']
|
||||
if 'trigger_config' in data:
|
||||
merged['trigger_config'] = json.dumps(data['trigger_config'])
|
||||
if 'then_actions' in data:
|
||||
merged['then_actions'] = json.dumps(data['then_actions'])
|
||||
merged['enabled'] = True
|
||||
test_autos.append(merged)
|
||||
else:
|
||||
test_autos.append(a)
|
||||
cycle = automation_engine.detect_signal_cycles(test_autos)
|
||||
if cycle:
|
||||
return jsonify({"error": f"Signal cycle detected: {' → '.join(cycle)}. This would cause an infinite loop."}), 400
|
||||
|
||||
success = db.update_automation(automation_id, **update_fields)
|
||||
if not success:
|
||||
return jsonify({"error": "Automation not found"}), 404
|
||||
|
|
@ -3749,6 +3821,35 @@ def get_automation_progress():
|
|||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
def _collect_known_signals():
|
||||
"""Collect all signal names used across automations (for autocomplete)."""
|
||||
signals = set()
|
||||
try:
|
||||
db = get_database()
|
||||
for auto in db.get_automations():
|
||||
# Check signal_received triggers
|
||||
if auto.get('trigger_type') == 'signal_received':
|
||||
try:
|
||||
tc = json.loads(auto.get('trigger_config') or '{}')
|
||||
sig = tc.get('signal_name', '').strip()
|
||||
if sig:
|
||||
signals.add(sig)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# Check fire_signal in then_actions
|
||||
try:
|
||||
ta = json.loads(auto.get('then_actions') or '[]')
|
||||
for item in ta:
|
||||
if item.get('type') == 'fire_signal':
|
||||
sig = item.get('config', {}).get('signal_name', '').strip()
|
||||
if sig:
|
||||
signals.add(sig)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(signals)
|
||||
|
||||
@app.route('/api/automations/blocks', methods=['GET'])
|
||||
def get_automation_blocks():
|
||||
"""Return available block types for the automation builder sidebar."""
|
||||
|
|
@ -3841,6 +3942,13 @@ def get_automation_blocks():
|
|||
{"type": "duplicate_scan_completed", "label": "Duplicate Scan Done", "icon": "layers",
|
||||
"description": "When duplicate cleaner finishes", "available": True,
|
||||
"variables": ["files_scanned", "duplicates_found", "space_freed"]},
|
||||
# Signal trigger
|
||||
{"type": "signal_received", "label": "Signal Received", "icon": "zap",
|
||||
"description": "When another automation fires a named signal", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"}
|
||||
],
|
||||
"variables": ["signal_name"]},
|
||||
],
|
||||
"actions": [
|
||||
{"type": "process_wishlist", "label": "Process Wishlist", "icon": "list", "description": "Retry failed downloads from wishlist", "available": True,
|
||||
|
|
@ -3893,7 +4001,14 @@ def get_automation_blocks():
|
|||
"variables": ["time", "name", "run_count", "status"]},
|
||||
{"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True,
|
||||
"variables": ["time", "name", "run_count", "status"]},
|
||||
]
|
||||
# Signal fire action
|
||||
{"type": "fire_signal", "label": "Fire Signal", "icon": "zap",
|
||||
"description": "Fire a signal that other automations can listen for", "available": True,
|
||||
"config_fields": [
|
||||
{"key": "signal_name", "type": "signal_input", "label": "Signal Name"}
|
||||
]},
|
||||
],
|
||||
"known_signals": _collect_known_signals(),
|
||||
})
|
||||
|
||||
@app.route('/api/mirrored-playlists/list', methods=['GET'])
|
||||
|
|
|
|||
|
|
@ -16187,6 +16187,60 @@ const TOOL_HELP_CONTENT = {
|
|||
<li>Peace of mind for your library data</li>
|
||||
</ul>
|
||||
`
|
||||
},
|
||||
|
||||
// ==================== Signal System Help ====================
|
||||
|
||||
'auto-signal_received': {
|
||||
title: 'Signal Received',
|
||||
content: `
|
||||
<h4>What is this trigger?</h4>
|
||||
<p>Fires when another automation sends a named signal using the <strong>Fire Signal</strong> then-action. This lets you chain automations together — one automation finishes and wakes up another.</p>
|
||||
|
||||
<h4>Configuration</h4>
|
||||
<ul>
|
||||
<li><strong>Signal Name:</strong> The name to listen for (e.g. <code>library_ready</code>, <code>scan_done</code>). Must match the name used in the Fire Signal action.</li>
|
||||
</ul>
|
||||
|
||||
<h4>How chaining works</h4>
|
||||
<ol>
|
||||
<li><strong>Automation A:</strong> Trigger = Batch Complete, Action = Scan Library, Then = Fire Signal "scan_done"</li>
|
||||
<li><strong>Automation B:</strong> Trigger = Signal Received "scan_done", Action = Update Database</li>
|
||||
<li>When a download finishes → A scans library → fires signal → B wakes up → updates database</li>
|
||||
</ol>
|
||||
|
||||
<h4>Safety</h4>
|
||||
<ul>
|
||||
<li>Circular signal chains are detected and blocked when you save</li>
|
||||
<li>Maximum chain depth of 5 levels to prevent runaway cascades</li>
|
||||
<li>Same signal can only fire once every 10 seconds (cooldown)</li>
|
||||
</ul>
|
||||
|
||||
<h4>Signal names</h4>
|
||||
<p>Use descriptive lowercase names with underscores: <code>library_ready</code>, <code>scan_complete</code>, <code>downloads_done</code>. Existing signal names from other automations appear as suggestions.</p>
|
||||
`
|
||||
},
|
||||
'auto-fire_signal': {
|
||||
title: 'Fire Signal',
|
||||
content: `
|
||||
<h4>What does this then-action do?</h4>
|
||||
<p>Fires a named signal after the automation's action completes. Any other automation with a <strong>Signal Received</strong> trigger listening for this signal name will wake up and run.</p>
|
||||
|
||||
<h4>Configuration</h4>
|
||||
<ul>
|
||||
<li><strong>Signal Name:</strong> The signal to fire (e.g. <code>library_ready</code>). Use the same name in a Signal Received trigger on another automation to connect them.</li>
|
||||
</ul>
|
||||
|
||||
<h4>Use cases</h4>
|
||||
<ul>
|
||||
<li><strong>Multi-step workflows:</strong> Scan library → fire signal → update database → fire signal → send notification</li>
|
||||
<li><strong>Fan-out:</strong> One signal can trigger multiple automations simultaneously</li>
|
||||
<li><strong>Decoupled logic:</strong> Keep each automation simple with one job, chain them via signals</li>
|
||||
</ul>
|
||||
|
||||
<h4>Combining with notifications</h4>
|
||||
<p>You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification — all run after the action completes.</p>
|
||||
`
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -43160,7 +43214,7 @@ async function discoverMirroredPlaylist(playlistId) {
|
|||
// ===============================
|
||||
|
||||
let _autoBlocks = null; // cached block definitions from /api/automations/blocks
|
||||
let _autoBuilder = { editId: null, when: null, do: null, notify: null };
|
||||
let _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false };
|
||||
|
||||
let _autoMirroredPlaylists = null; // cached mirrored playlist list
|
||||
|
||||
|
|
@ -43172,6 +43226,7 @@ const _autoIcons = {
|
|||
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
|
||||
discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D',
|
||||
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F',
|
||||
signal_received: '\u26A1', fire_signal: '\u26A1',
|
||||
// Phase 3
|
||||
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
|
||||
database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C',
|
||||
|
|
@ -43237,7 +43292,7 @@ function renderAutomationCard(a) {
|
|||
const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F';
|
||||
const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config);
|
||||
const al = aIcon + ' ' + _autoFormatAction(a.action_type);
|
||||
const nl = a.notify_type ? _autoFormatNotify(a.notify_type) : '';
|
||||
const thenItems = a.then_actions || [];
|
||||
const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0;
|
||||
const metaParts = [];
|
||||
if (a.is_system) metaParts.push('<span class="system-badge">System</span>');
|
||||
|
|
@ -43260,7 +43315,7 @@ function renderAutomationCard(a) {
|
|||
<span class="flow-arrow">→</span>
|
||||
${actionDelay ? `<span class="flow-delay">\u23F3 ${actionDelay}m</span><span class="flow-arrow">→</span>` : ''}
|
||||
<span class="flow-action">${_esc(al)}</span>
|
||||
${nl ? `<span class="flow-arrow">→</span><span class="flow-notify">${_esc(nl)}</span>` : ''}
|
||||
${thenItems.length ? thenItems.map(t => `<span class="flow-arrow">→</span><span class="flow-notify">${_esc(_autoFormatNotify(t.type))}</span>`).join('') : ''}
|
||||
</div>
|
||||
<div class="automation-meta">${metaParts.join(' · ')}</div>
|
||||
</div>
|
||||
|
|
@ -43284,6 +43339,10 @@ function _autoFormatTrigger(type, config) {
|
|||
const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ');
|
||||
return (days || 'Every day') + ' at ' + (config.time || '00:00');
|
||||
}
|
||||
if (type === 'signal_received' && config) {
|
||||
const sig = config.signal_name || 'unknown';
|
||||
return 'Signal: ' + sig;
|
||||
}
|
||||
const labels = { app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete',
|
||||
watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced',
|
||||
playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete',
|
||||
|
|
@ -43292,7 +43351,8 @@ function _autoFormatTrigger(type, config) {
|
|||
download_quarantined: 'File Quarantined', wishlist_item_added: 'Wishlist Item Added',
|
||||
watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched',
|
||||
import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored',
|
||||
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done' };
|
||||
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done',
|
||||
signal_received: 'Signal Received' };
|
||||
let label = labels[type] || type || 'Unknown';
|
||||
if (config && config.conditions && config.conditions.length) {
|
||||
const first = config.conditions[0];
|
||||
|
|
@ -43316,6 +43376,7 @@ function _autoFormatNotify(type) {
|
|||
if (type === 'discord_webhook') return 'Discord';
|
||||
if (type === 'pushbullet') return 'Pushbullet';
|
||||
if (type === 'telegram') return 'Telegram';
|
||||
if (type === 'fire_signal') return '\u26A1 Signal';
|
||||
return type || '';
|
||||
}
|
||||
function _autoTimeAgo(ts) {
|
||||
|
|
@ -43473,7 +43534,12 @@ async function saveAutomation() {
|
|||
// Read configs from DOM
|
||||
const triggerConfig = _readPlacedConfig('when');
|
||||
const actionConfig = _readPlacedConfig('do');
|
||||
const notifyConfig = _autoBuilder.notify ? _readPlacedConfig('notify') : {};
|
||||
|
||||
// Read THEN actions (multi-slot)
|
||||
const thenActions = _autoBuilder.then.map((item, i) => ({
|
||||
type: item.type,
|
||||
config: _readPlacedConfig('then-' + i),
|
||||
}));
|
||||
|
||||
// Read optional delay from DO slot
|
||||
const delayEl = document.getElementById('cfg-do-delay');
|
||||
|
|
@ -43484,8 +43550,7 @@ async function saveAutomation() {
|
|||
name,
|
||||
trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig,
|
||||
action_type: _autoBuilder.do.type, action_config: actionConfig,
|
||||
notify_type: _autoBuilder.notify ? _autoBuilder.notify.type : null,
|
||||
notify_config: _autoBuilder.notify ? notifyConfig : {},
|
||||
then_actions: thenActions,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -43515,7 +43580,7 @@ async function showAutomationBuilder(editId) {
|
|||
}
|
||||
|
||||
_autoMirroredPlaylists = null; // invalidate so it re-fetches
|
||||
_autoBuilder = { editId: editId || null, when: null, do: null, notify: null, isSystem: false };
|
||||
_autoBuilder = { editId: editId || null, when: null, do: null, then: [], isSystem: false };
|
||||
|
||||
// If editing, load automation data
|
||||
if (editId) {
|
||||
|
|
@ -43526,7 +43591,14 @@ async function showAutomationBuilder(editId) {
|
|||
document.getElementById('builder-name').value = a.name || '';
|
||||
_autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} };
|
||||
_autoBuilder.do = { type: a.action_type, config: a.action_config || {} };
|
||||
if (a.notify_type) _autoBuilder.notify = { type: a.notify_type, config: a.notify_config || {} };
|
||||
// Load then_actions array
|
||||
_autoBuilder.then = (a.then_actions || []).map(item => ({
|
||||
type: item.type, config: item.config || {}
|
||||
}));
|
||||
// Backward compat: if no then_actions but has notify_type
|
||||
if (!_autoBuilder.then.length && a.notify_type) {
|
||||
_autoBuilder.then = [{ type: a.notify_type, config: a.notify_config || {} }];
|
||||
}
|
||||
_autoBuilder.isSystem = !!a.is_system;
|
||||
} catch (err) { showToast('Failed to load automation', 'error'); return; }
|
||||
} else {
|
||||
|
|
@ -43547,7 +43619,7 @@ function hideAutomationBuilder() {
|
|||
document.getElementById('automations-builder-view').style.display = 'none';
|
||||
document.getElementById('automations-list-view').style.display = '';
|
||||
document.getElementById('builder-name').readOnly = false;
|
||||
_autoBuilder = { editId: null, when: null, do: null, notify: null, isSystem: false };
|
||||
_autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false };
|
||||
}
|
||||
|
||||
// --- Sidebar ---
|
||||
|
|
@ -43560,7 +43632,7 @@ function _renderBuilderSidebar() {
|
|||
const sections = [
|
||||
{ key: 'triggers', title: 'Triggers', slot: 'when' },
|
||||
{ key: 'actions', title: 'Actions', slot: 'do' },
|
||||
{ key: 'notifications', title: 'Notifications', slot: 'notify' },
|
||||
{ key: 'notifications', title: 'Then', slot: 'then' },
|
||||
];
|
||||
|
||||
sections.forEach(sec => {
|
||||
|
|
@ -43592,35 +43664,70 @@ function _renderBuilderCanvas() {
|
|||
if (!canvas) return;
|
||||
|
||||
let html = '';
|
||||
const slots = [
|
||||
{ key: 'when', label: 'WHEN', labelClass: 'when', prompt: 'Drag a trigger here — WHEN does this run?' },
|
||||
{ key: 'do', label: 'DO', labelClass: 'do', prompt: 'Drag an action here — WHAT should it do?' },
|
||||
{ key: 'notify', label: 'NOTIFY', labelClass: 'notify', prompt: 'Drag a notification here (optional)' },
|
||||
];
|
||||
|
||||
slots.forEach((slot, i) => {
|
||||
if (i > 0) html += '<div class="flow-connector"></div>';
|
||||
const data = _autoBuilder[slot.key];
|
||||
html += `<span class="flow-slot-label ${slot.labelClass}">${slot.label}</span>`;
|
||||
if (data) {
|
||||
html += `<div class="flow-slot filled" id="slot-${slot.key}" ondragover="_autoDragOver(event,'${slot.key}')" ondragleave="_autoDragLeave(event,'${slot.key}')" ondrop="_autoDrop(event,'${slot.key}')">
|
||||
${_renderPlacedBlock(slot.key, data)}
|
||||
// WHEN slot
|
||||
const whenData = _autoBuilder.when;
|
||||
html += '<span class="flow-slot-label when">WHEN</span>';
|
||||
if (whenData) {
|
||||
html += `<div class="flow-slot filled" id="slot-when" ondragover="_autoDragOver(event,'when')" ondragleave="_autoDragLeave(event,'when')" ondrop="_autoDrop(event,'when')">
|
||||
${_renderPlacedBlock('when', whenData)}
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="flow-slot empty" id="slot-when" ondragover="_autoDragOver(event,'when')" ondragleave="_autoDragLeave(event,'when')" ondrop="_autoDrop(event,'when')">
|
||||
<div class="flow-slot-prompt">Drag a trigger here — WHEN does this run?</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += '<div class="flow-connector"></div>';
|
||||
|
||||
// DO slot
|
||||
const doData = _autoBuilder.do;
|
||||
html += '<span class="flow-slot-label do">DO</span>';
|
||||
if (doData) {
|
||||
html += `<div class="flow-slot filled" id="slot-do" ondragover="_autoDragOver(event,'do')" ondragleave="_autoDragLeave(event,'do')" ondrop="_autoDrop(event,'do')">
|
||||
${_renderPlacedBlock('do', doData)}
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="flow-slot empty" id="slot-do" ondragover="_autoDragOver(event,'do')" ondragleave="_autoDragLeave(event,'do')" ondrop="_autoDrop(event,'do')">
|
||||
<div class="flow-slot-prompt">Drag an action here — WHAT should it do?</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += '<div class="flow-connector"></div>';
|
||||
|
||||
// THEN section (multi-slot, 1-3 items)
|
||||
html += '<span class="flow-slot-label then">THEN</span>';
|
||||
if (_autoBuilder.then.length > 0) {
|
||||
_autoBuilder.then.forEach((item, i) => {
|
||||
if (i > 0) html += '<div class="flow-connector small"></div>';
|
||||
html += `<div class="flow-slot filled then-slot" id="slot-then-${i}">
|
||||
${_renderPlacedBlock('then-' + i, item)}
|
||||
</div>`;
|
||||
} else {
|
||||
html += `<div class="flow-slot empty" id="slot-${slot.key}" ondragover="_autoDragOver(event,'${slot.key}')" ondragleave="_autoDragLeave(event,'${slot.key}')" ondrop="_autoDrop(event,'${slot.key}')">
|
||||
<div class="flow-slot-prompt">${slot.prompt}</div>
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (_autoBuilder.then.length < 3) {
|
||||
if (_autoBuilder.then.length > 0) html += '<div class="flow-connector small"></div>';
|
||||
html += `<div class="flow-slot empty then-add" id="slot-then-add"
|
||||
ondragover="_autoDragOver(event,'then')" ondragleave="_autoDragLeave(event,'then')" ondrop="_autoDrop(event,'then')">
|
||||
<div class="flow-slot-prompt">${_autoBuilder.then.length === 0
|
||||
? 'Drag a then-action here (optional)'
|
||||
: '+ Add another (max 3)'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
canvas.innerHTML = html;
|
||||
// Load mirrored playlist selects if any are present
|
||||
_autoLoadMirroredSelects();
|
||||
// Set up checkbox state for refresh_mirrored
|
||||
['when', 'do', 'notify'].forEach(sk => {
|
||||
['when', 'do'].forEach(sk => {
|
||||
const allCb = document.getElementById('cfg-' + sk + '-all');
|
||||
if (allCb) _autoTogglePlaylistSelect(sk);
|
||||
});
|
||||
// Also check then slots
|
||||
_autoBuilder.then.forEach((item, i) => {
|
||||
const allCb = document.getElementById('cfg-then-' + i + '-all');
|
||||
if (allCb) _autoTogglePlaylistSelect('then-' + i);
|
||||
});
|
||||
}
|
||||
|
||||
function _renderPlacedBlock(slotKey, data) {
|
||||
|
|
@ -43708,6 +43815,36 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
|
|||
</select>
|
||||
</div>`;
|
||||
}
|
||||
if (blockType === 'signal_received') {
|
||||
const sigName = _escAttr(config.signal_name || '');
|
||||
const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || [];
|
||||
return `<div class="config-row">
|
||||
<label>Signal Name</label>
|
||||
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
|
||||
list="known-signals-list-${slotKey}" placeholder="e.g. library_ready"
|
||||
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
|
||||
style="font-family:monospace;">
|
||||
<datalist id="known-signals-list-${slotKey}">
|
||||
${knownSignals.map(s => `<option value="${s}">`).join('')}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Triggers when another automation fires this signal</div>`;
|
||||
}
|
||||
if (blockType === 'fire_signal') {
|
||||
const sigName = _escAttr(config.signal_name || '');
|
||||
const knownSignals = (_autoBlocks && _autoBlocks.known_signals) || [];
|
||||
return `<div class="config-row">
|
||||
<label>Signal Name</label>
|
||||
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
|
||||
list="known-signals-fire-${slotKey}" placeholder="e.g. library_ready"
|
||||
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
|
||||
style="font-family:monospace;">
|
||||
<datalist id="known-signals-fire-${slotKey}">
|
||||
${knownSignals.map(s => `<option value="${s}">`).join('')}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Other automations with "Signal Received" trigger will wake up</div>`;
|
||||
}
|
||||
if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') {
|
||||
return '<div class="config-row" style="color:rgba(255,255,255,0.4);font-size:12px;">No configuration needed</div>';
|
||||
}
|
||||
|
|
@ -43948,7 +44085,13 @@ async function _autoLoadMirroredSelects() {
|
|||
}
|
||||
|
||||
function _readPlacedConfig(slotKey) {
|
||||
const data = _autoBuilder[slotKey];
|
||||
let data;
|
||||
if (slotKey.startsWith('then-')) {
|
||||
const idx = parseInt(slotKey.split('-')[1]);
|
||||
data = _autoBuilder.then[idx];
|
||||
} else {
|
||||
data = _autoBuilder[slotKey];
|
||||
}
|
||||
if (!data) return {};
|
||||
const type = data.type;
|
||||
if (type === 'schedule') {
|
||||
|
|
@ -43997,6 +44140,9 @@ function _readPlacedConfig(slotKey) {
|
|||
all: allCb ? allCb.checked : false,
|
||||
};
|
||||
}
|
||||
if (type === 'signal_received' || type === 'fire_signal') {
|
||||
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
|
||||
}
|
||||
if (type === 'discord_webhook') {
|
||||
return {
|
||||
webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '',
|
||||
|
|
@ -44039,21 +44185,31 @@ function _autoDragStart(e, blockType, slotCategory) {
|
|||
function _autoDragOver(e, slotKey) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
document.getElementById('slot-' + slotKey)?.classList.add('drag-over');
|
||||
const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
|
||||
document.getElementById(targetId)?.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function _autoDragLeave(e, slotKey) {
|
||||
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
|
||||
const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
|
||||
document.getElementById(targetId)?.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
function _autoDrop(e, slotKey) {
|
||||
e.preventDefault();
|
||||
document.getElementById('slot-' + slotKey)?.classList.remove('drag-over');
|
||||
const dropTargetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey;
|
||||
document.getElementById(dropTargetId)?.classList.remove('drag-over');
|
||||
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||
if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
|
||||
_autoBuilder[slotKey] = { type: data.type, config: {} };
|
||||
// Handle THEN slot (append to array)
|
||||
if (slotKey === 'then') {
|
||||
if (data.slot !== 'then') { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
|
||||
if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; }
|
||||
_autoBuilder.then.push({ type: data.type, config: {} });
|
||||
} else {
|
||||
if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; }
|
||||
_autoBuilder[slotKey] = { type: data.type, config: {} };
|
||||
}
|
||||
_renderBuilderCanvas();
|
||||
} catch (err) {}
|
||||
}
|
||||
|
|
@ -44061,13 +44217,26 @@ function _autoDrop(e, slotKey) {
|
|||
// Click-to-add (alternative to drag)
|
||||
function _autoClickBlock(blockType, slotCategory) {
|
||||
if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return;
|
||||
_autoBuilder[slotCategory] = { type: blockType, config: {} };
|
||||
if (slotCategory === 'then') {
|
||||
if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; }
|
||||
_autoBuilder.then.push({ type: blockType, config: {} });
|
||||
} else {
|
||||
_autoBuilder[slotCategory] = { type: blockType, config: {} };
|
||||
}
|
||||
_renderBuilderCanvas();
|
||||
}
|
||||
|
||||
function _autoRemoveBlock(slotKey) {
|
||||
if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return;
|
||||
_autoBuilder[slotKey] = null;
|
||||
// Handle then-N slots
|
||||
if (slotKey.startsWith('then-')) {
|
||||
const idx = parseInt(slotKey.split('-')[1]);
|
||||
if (!isNaN(idx) && idx >= 0 && idx < _autoBuilder.then.length) {
|
||||
_autoBuilder.then.splice(idx, 1);
|
||||
}
|
||||
} else {
|
||||
_autoBuilder[slotKey] = null;
|
||||
}
|
||||
_renderBuilderCanvas();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29110,6 +29110,18 @@ body {
|
|||
.flow-slot-label.when { background: rgba(var(--accent-rgb),0.2); color: rgb(var(--accent-light-rgb)); }
|
||||
.flow-slot-label.do { background: rgba(88,101,242,0.2); color: #7289da; }
|
||||
.flow-slot-label.notify { background: rgba(250,204,21,0.15); color: #fbbf24; }
|
||||
.flow-slot-label.then { background: rgba(250,204,21,0.15); color: #fbbf24; }
|
||||
|
||||
.flow-connector.small { height: 16px; }
|
||||
|
||||
.then-add {
|
||||
min-height: 48px !important;
|
||||
border-style: dashed;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.then-add:hover, .then-add.drag-over { opacity: 1; }
|
||||
.then-add .flow-slot-prompt { font-size: 12px; padding: 12px; }
|
||||
|
||||
/* --- Placed Block (in flow) --- */
|
||||
.placed-block {
|
||||
|
|
|
|||
Loading…
Reference in a new issue