import { useEffect, useMemo, useRef, useState } from 'react'; import { Card } from 'primereact/card'; import { Button } from 'primereact/button'; import { Toast } from 'primereact/toast'; import { Dialog } from 'primereact/dialog'; import { TabView, TabPanel } from 'primereact/tabview'; import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; import { Dropdown } from 'primereact/dropdown'; import { InputSwitch } from 'primereact/inputswitch'; import { api } from '../api/client'; import DynamicSettingsForm from '../components/DynamicSettingsForm'; import CronJobsTab from '../components/CronJobsTab'; import { confirmModal } from '../utils/confirmModal'; const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode'; const USER_PRESET_DEFAULT_SLOTS = Object.freeze([ { key: 'bluray_movie', label: 'Standard-Userpreset für BluRay Film', mediaType: 'bluray' }, { key: 'bluray_series', label: 'Standard-Userpreset für BluRay Serie', mediaType: 'bluray' }, { key: 'dvd_movie', label: 'Standard-Userpreset für DVD Film', mediaType: 'dvd' }, { key: 'dvd_series', label: 'Standard-Userpreset für DVD Serie', mediaType: 'dvd' } ]); const EMPTY_USER_PRESET_DEFAULTS = Object.freeze( USER_PRESET_DEFAULT_SLOTS.reduce((acc, slot) => ({ ...acc, [slot.key]: null }), {}) ); // Validates template/string fields: only letters, digits, space, _, -, and {placeholders} allowed function validateSettingValue(key, value) { if (!key.includes('template')) return null; const str = String(value ?? ''); if (!str) return null; // Strip valid placeholder patterns ${...} and {...} const stripped = str.replace(/\$\{[^}]*\}/g, '').replace(/\{[^}]*\}/g, ''); // Standalone { or } after stripping = unclosed placeholder if (/[{}]/.test(stripped)) { return 'Geschweifte Klammern sind nur als vollständige Platzhalter erlaubt, z.B. {title}.'; } // Find first invalid special char (anything beyond letters, digits, space, _, -, (), [], /) // . is not allowed (extensions are set separately); / is only for folder separators const invalid = stripped.match(/[^a-zA-Z0-9 ()[\]/_-]/); if (invalid) { return `Ungültiges Zeichen "${invalid[0]}". Erlaubt sind nur Buchstaben, Ziffern, Leerzeichen, _, -, und {Platzhalter}.`; } return null; } function buildValuesMap(categories) { const next = {}; for (const category of categories || []) { for (const setting of category.settings || []) { next[setting.key] = setting.value; } } return next; } function normalizePositiveId(value) { if (value === null || value === undefined) { return null; } if (typeof value === 'string' && value.trim() === '') { return null; } const numeric = Number(value); if (!Number.isFinite(numeric) || numeric <= 0) { return null; } return Math.trunc(numeric); } function normalizeUserPresetDefaultsMap(raw = {}) { const source = raw && typeof raw === 'object' ? raw : {}; const normalized = { ...EMPTY_USER_PRESET_DEFAULTS }; for (const slot of USER_PRESET_DEFAULT_SLOTS) { normalized[slot.key] = normalizePositiveId(source[slot.key]); } return normalized; } function isPresetCompatibleWithSlot(preset, slotMediaType) { const mediaType = String(preset?.mediaType || '').trim().toLowerCase(); if (mediaType === 'all') { return true; } return mediaType === slotMediaType; } function buildUserPresetDefaultOptions(userPresets, slotMediaType, selectedPresetId = null) { const presets = Array.isArray(userPresets) ? userPresets : []; const options = [{ label: '(keine Standardzuordnung)', value: null }]; const seen = new Set([null]); for (const preset of presets) { const presetId = normalizePositiveId(preset?.id); if (!presetId || !isPresetCompatibleWithSlot(preset, slotMediaType)) { continue; } seen.add(presetId); options.push({ label: `#${presetId} - ${preset?.name || 'Preset'}`, value: presetId }); } const normalizedSelectedId = normalizePositiveId(selectedPresetId); if (normalizedSelectedId && !seen.has(normalizedSelectedId)) { const existingPreset = presets.find((preset) => normalizePositiveId(preset?.id) === normalizedSelectedId); options.push({ label: existingPreset ? `#${normalizedSelectedId} - ${existingPreset?.name || 'Preset'} (nicht kompatibel)` : `#${normalizedSelectedId} - (nicht gefunden)`, value: normalizedSelectedId }); } return options; } function isSameValue(a, b) { if (typeof a === 'number' && typeof b === 'number') { return Number(a) === Number(b); } return a === b; } function toBoolean(value) { if (typeof value === 'boolean') { return value; } if (typeof value === 'number') { return value !== 0; } const normalized = String(value || '').trim().toLowerCase(); return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; } function reorderListById(items, sourceId, targetIndex) { const list = Array.isArray(items) ? items : []; const normalizedSourceId = Number(sourceId); const normalizedTargetIndex = Number(targetIndex); if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0 || !Number.isFinite(normalizedTargetIndex)) { return { changed: false, next: list }; } const fromIndex = list.findIndex((item) => Number(item?.id) === normalizedSourceId); if (fromIndex < 0) { return { changed: false, next: list }; } const boundedTarget = Math.max(0, Math.min(Math.trunc(normalizedTargetIndex), list.length)); const insertAt = fromIndex < boundedTarget ? boundedTarget - 1 : boundedTarget; if (insertAt === fromIndex) { return { changed: false, next: list }; } const next = [...list]; const [moved] = next.splice(fromIndex, 1); next.splice(insertAt, 0, moved); return { changed: true, next }; } function buildHandBrakePresetSelectOptions(sourceOptions, extraValues = []) { const rawOptions = Array.isArray(sourceOptions) ? sourceOptions : []; const rawExtraValues = Array.isArray(extraValues) ? extraValues : []; const normalizedOptions = []; const seenValues = new Set(); const seenGroupLabels = new Set(); const addGroupOption = (option) => { const rawLabel = String(option?.label || '').trim(); if (!rawLabel || seenGroupLabels.has(rawLabel)) { return; } seenGroupLabels.add(rawLabel); normalizedOptions.push({ ...option, label: rawLabel, value: String(option?.value || `__group__${rawLabel.toLowerCase().replace(/\s+/g, '_')}`), disabled: true }); }; const addSelectableOption = (optionValue, optionLabel = optionValue, option = null) => { const value = String(optionValue || '').trim(); if (seenValues.has(value)) { return; } seenValues.add(value); normalizedOptions.push({ ...(option && typeof option === 'object' ? option : {}), label: String(optionLabel ?? value), value, disabled: false }); }; normalizedOptions.push({ label: '(kein Preset – nur CLI-Parameter)', value: '', disabled: false }); seenValues.add(''); for (const option of rawOptions) { if (option?.disabled) { addGroupOption(option); continue; } addSelectableOption(option?.value, option?.label, option); } for (const value of rawExtraValues) { addSelectableOption(value); } return normalizedOptions; } function injectHandBrakePresetOptions(categories, presetPayload) { const list = Array.isArray(categories) ? categories : []; const sourceOptions = Array.isArray(presetPayload?.options) ? presetPayload.options : []; const presetSettingKeys = new Set(['handbrake_preset', 'handbrake_preset_bluray', 'handbrake_preset_dvd']); return list.map((category) => ({ ...category, settings: (category?.settings || []).map((setting) => { if (!presetSettingKeys.has(String(setting?.key || '').trim().toLowerCase())) { return setting; } const normalizedOptions = buildHandBrakePresetSelectOptions(sourceOptions, [ setting?.value, setting?.defaultValue ]); if (normalizedOptions.length <= 1) { return setting; } return { ...setting, type: 'select', options: normalizedOptions }; }) })); } export default function SettingsPage() { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testingPushover, setTestingPushover] = useState(false); const [coverArtRecoveryBusy, setCoverArtRecoveryBusy] = useState(false); const [updatingExpertMode, setUpdatingExpertMode] = useState(false); const [activeTabIndex, setActiveTabIndex] = useState(0); const [initialValues, setInitialValues] = useState({}); const [draftValues, setDraftValues] = useState({}); const [errors, setErrors] = useState({}); const [scripts, setScripts] = useState([]); const [scriptsLoading, setScriptsLoading] = useState(false); const [scriptSaving, setScriptSaving] = useState(false); const [scriptReordering, setScriptReordering] = useState(false); const [scriptListDragSourceId, setScriptListDragSourceId] = useState(null); const [scriptActionBusyId, setScriptActionBusyId] = useState(null); const [scriptEditor, setScriptEditor] = useState({ mode: 'none', id: null, name: '', scriptBody: '' }); const [scriptErrors, setScriptErrors] = useState({}); const [lastScriptTestResult, setLastScriptTestResult] = useState(null); // Script chains state const [chains, setChains] = useState([]); const [chainsLoading, setChainsLoading] = useState(false); const [chainSaving, setChainSaving] = useState(false); const [chainReordering, setChainReordering] = useState(false); const [chainListDragSourceId, setChainListDragSourceId] = useState(null); const [chainActionBusyId, setChainActionBusyId] = useState(null); const [lastChainTestResult, setLastChainTestResult] = useState(null); const [chainEditor, setChainEditor] = useState({ open: false, id: null, name: '', steps: [] }); const [chainEditorErrors, setChainEditorErrors] = useState({}); const [chainDragSource, setChainDragSource] = useState(null); // Activation Bytes state const [activationBytes, setActivationBytes] = useState([]); // User presets state const [userPresets, setUserPresets] = useState([]); const [userPresetsLoading, setUserPresetsLoading] = useState(false); const [userPresetSaving, setUserPresetSaving] = useState(false); const [userPresetDefaults, setUserPresetDefaults] = useState(EMPTY_USER_PRESET_DEFAULTS); const [userPresetDefaultsDraft, setUserPresetDefaultsDraft] = useState(EMPTY_USER_PRESET_DEFAULTS); const [userPresetDefaultsLoading, setUserPresetDefaultsLoading] = useState(false); const [userPresetDefaultsSaving, setUserPresetDefaultsSaving] = useState(false); const [userPresetEditor, setUserPresetEditor] = useState({ open: false, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' }); const [userPresetErrors, setUserPresetErrors] = useState({}); const [handBrakePresetSourceOptions, setHandBrakePresetSourceOptions] = useState([]); const [effectivePaths, setEffectivePaths] = useState(null); const toastRef = useRef(null); const userPresetHandBrakeOptions = useMemo( () => buildHandBrakePresetSelectOptions( handBrakePresetSourceOptions, [userPresetEditor.handbrakePreset] ), [handBrakePresetSourceOptions, userPresetEditor.handbrakePreset] ); const userPresetDefaultsDirty = useMemo( () => USER_PRESET_DEFAULT_SLOTS.some((slot) => ( normalizePositiveId(userPresetDefaultsDraft?.[slot.key]) !== normalizePositiveId(userPresetDefaults?.[slot.key]) )), [userPresetDefaultsDraft, userPresetDefaults] ); const userPresetDefaultOptionsBySlot = useMemo(() => { const next = {}; for (const slot of USER_PRESET_DEFAULT_SLOTS) { next[slot.key] = buildUserPresetDefaultOptions( userPresets, slot.mediaType, userPresetDefaultsDraft?.[slot.key] ); } return next; }, [userPresets, userPresetDefaultsDraft]); const loadScripts = async ({ silent = false } = {}) => { if (!silent) { setScriptsLoading(true); } try { const response = await api.getScripts(); const next = Array.isArray(response?.scripts) ? response.scripts : []; setScripts(next); } catch (error) { if (!silent) { toastRef.current?.show({ severity: 'error', summary: 'Script-Liste', detail: error.message }); } } finally { if (!silent) { setScriptsLoading(false); } } }; const loadChains = async ({ silent = false } = {}) => { if (!silent) { setChainsLoading(true); } try { const response = await api.getScriptChains(); setChains(Array.isArray(response?.chains) ? response.chains : []); } catch (error) { if (!silent) { toastRef.current?.show({ severity: 'error', summary: 'Skriptketten', detail: error.message }); } } finally { if (!silent) { setChainsLoading(false); } } }; const loadUserPresets = async ({ silent = false } = {}) => { if (!silent) { setUserPresetsLoading(true); } try { const response = await api.getUserPresets(); setUserPresets(Array.isArray(response?.presets) ? response.presets : []); } catch (error) { if (!silent) { toastRef.current?.show({ severity: 'error', summary: 'User-Presets', detail: error.message }); } } finally { if (!silent) { setUserPresetsLoading(false); } } }; const loadUserPresetDefaults = async ({ silent = false } = {}) => { if (!silent) { setUserPresetDefaultsLoading(true); } try { const response = await api.getUserPresetDefaults(); const normalized = normalizeUserPresetDefaultsMap(response?.defaults || {}); setUserPresetDefaults(normalized); setUserPresetDefaultsDraft(normalized); } catch (error) { if (!silent) { toastRef.current?.show({ severity: 'error', summary: 'Preset-Standards', detail: error.message }); } } finally { if (!silent) { setUserPresetDefaultsLoading(false); } } }; const openNewUserPreset = () => { setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' }); setUserPresetErrors({}); }; const openEditUserPreset = (preset) => { setUserPresetEditor({ open: true, id: preset.id, name: preset.name || '', mediaType: preset.mediaType || 'all', handbrakePreset: preset.handbrakePreset || '', extraArgs: preset.extraArgs || '', description: preset.description || '' }); setUserPresetErrors({}); }; const closeUserPresetEditor = () => { setUserPresetEditor((prev) => ({ ...prev, open: false })); setUserPresetErrors({}); }; const handleSaveUserPreset = async () => { const errors = {}; if (!userPresetEditor.name.trim()) { errors.name = 'Name ist erforderlich.'; } if (Object.keys(errors).length > 0) { setUserPresetErrors(errors); return; } setUserPresetSaving(true); try { const payload = { name: userPresetEditor.name.trim(), mediaType: userPresetEditor.mediaType, handbrakePreset: userPresetEditor.handbrakePreset.trim(), extraArgs: userPresetEditor.extraArgs.trim(), description: userPresetEditor.description.trim() }; if (userPresetEditor.id) { await api.updateUserPreset(userPresetEditor.id, payload); toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset aktualisiert.' }); } else { await api.createUserPreset(payload); toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset erstellt.' }); } closeUserPresetEditor(); await loadUserPresets({ silent: true }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Preset speichern', detail: error.message }); } finally { setUserPresetSaving(false); } }; const handleDeleteUserPreset = async (presetId) => { try { await api.deleteUserPreset(presetId); toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset gelöscht.' }); await Promise.all([ loadUserPresets({ silent: true }), loadUserPresetDefaults({ silent: true }) ]); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Preset löschen', detail: error.message }); } }; const handleChangeUserPresetDefault = (slotKey, rawValue) => { const normalized = normalizePositiveId(rawValue); setUserPresetDefaultsDraft((prev) => ({ ...prev, [slotKey]: normalized })); }; const handleResetUserPresetDefaults = () => { setUserPresetDefaultsDraft({ ...userPresetDefaults }); }; const handleSaveUserPresetDefaults = async () => { if (!userPresetDefaultsDirty) { toastRef.current?.show({ severity: 'info', summary: 'Preset-Standards', detail: 'Keine Änderungen zum Speichern.' }); return; } setUserPresetDefaultsSaving(true); try { const payload = normalizeUserPresetDefaultsMap(userPresetDefaultsDraft); const response = await api.updateUserPresetDefaults(payload); const normalized = normalizeUserPresetDefaultsMap(response?.defaults || payload); setUserPresetDefaults(normalized); setUserPresetDefaultsDraft(normalized); toastRef.current?.show({ severity: 'success', summary: 'Preset-Standards', detail: 'Standard-Zuordnungen gespeichert.' }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Preset-Standards', detail: error.message }); } finally { setUserPresetDefaultsSaving(false); } }; const loadEffectivePaths = async ({ silent = false } = {}) => { try { const paths = await api.getEffectivePaths({ forceRefresh: true }); setEffectivePaths(paths || null); } catch (_error) { if (!silent) { setEffectivePaths(null); } } }; const load = async () => { setLoading(true); try { const settingsResponse = await api.getSettings(); let nextCategories = settingsResponse?.categories || []; const values = buildValuesMap(nextCategories); setCategories(nextCategories); setInitialValues(values); setDraftValues(values); setErrors({}); loadEffectivePaths({ silent: true }); api.getActivationBytes().then(r => setActivationBytes(Array.isArray(r?.entries) ? r.entries : [])).catch(() => {}); const presetsPromise = api.getHandBrakePresets(); const scriptsPromise = api.getScripts(); const chainsPromise = api.getScriptChains(); const [scriptsResponse, chainsResponse] = await Promise.allSettled([scriptsPromise, chainsPromise]); if (scriptsResponse.status === 'fulfilled') { setScripts(Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : []); } else { toastRef.current?.show({ severity: 'warn', summary: 'Scripte', detail: 'Script-Liste konnte nicht geladen werden.' }); } if (chainsResponse.status === 'fulfilled') { setChains(Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : []); } presetsPromise .then((presetPayload) => { setHandBrakePresetSourceOptions(Array.isArray(presetPayload?.options) ? presetPayload.options : []); setCategories((prevCategories) => injectHandBrakePresetOptions(prevCategories, presetPayload)); if (presetPayload?.message) { toastRef.current?.show({ severity: presetPayload?.source === 'fallback' ? 'warn' : 'info', summary: 'HandBrake Presets', detail: presetPayload.message }); } }) .catch(() => { setHandBrakePresetSourceOptions([]); toastRef.current?.show({ severity: 'warn', summary: 'HandBrake Presets', detail: 'Preset-Liste konnte nicht geladen werden. Aktueller Wert bleibt auswählbar.' }); }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message }); } finally { setLoading(false); } }; useEffect(() => { load(); loadUserPresets(); loadUserPresetDefaults(); }, []); const dirtyKeys = useMemo(() => { const keys = new Set(); const allKeys = new Set([...Object.keys(initialValues), ...Object.keys(draftValues)]); for (const key of allKeys) { if (!isSameValue(initialValues[key], draftValues[key])) { keys.add(key); } } return keys; }, [initialValues, draftValues]); const hasUnsavedChanges = dirtyKeys.size > 0; const expertModeEnabled = toBoolean(draftValues?.[EXPERT_MODE_SETTING_KEY]); const handleFieldChange = (key, value) => { setDraftValues((prev) => ({ ...prev, [key]: value })); const validationError = validateSettingValue(key, value); setErrors((prev) => ({ ...prev, [key]: validationError || null })); }; const handleExpertModeToggle = async (checked) => { const previousDraftValue = draftValues?.[EXPERT_MODE_SETTING_KEY]; const previousInitialValue = initialValues?.[EXPERT_MODE_SETTING_KEY]; const nextValue = Boolean(checked); const currentValue = toBoolean(previousDraftValue); if (nextValue === currentValue) { return; } setUpdatingExpertMode(true); setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue })); setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: nextValue })); setErrors((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: null })); try { await api.updateSetting(EXPERT_MODE_SETTING_KEY, nextValue); } catch (error) { setDraftValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousDraftValue })); setInitialValues((prev) => ({ ...prev, [EXPERT_MODE_SETTING_KEY]: previousInitialValue })); toastRef.current?.show({ severity: 'error', summary: 'Expertenmodus', detail: error.message }); } finally { setUpdatingExpertMode(false); } }; const handleSave = async () => { if (!hasUnsavedChanges) { toastRef.current?.show({ severity: 'info', summary: 'Settings', detail: 'Keine Änderungen zum Speichern.' }); return; } const hasValidationErrors = Object.values(errors).some(Boolean); if (hasValidationErrors) { toastRef.current?.show({ severity: 'error', summary: 'Validierungsfehler', detail: 'Bitte korrigiere die fehlerhaften Felder vor dem Speichern.' }); return; } const patch = {}; for (const key of dirtyKeys) { patch[key] = draftValues[key]; } setSaving(true); try { const response = await api.updateSettingsBulk(patch); setInitialValues((prev) => ({ ...prev, ...patch })); setErrors({}); loadEffectivePaths({ silent: true }); const reviewRefresh = response?.reviewRefresh || null; const reviewRefreshHint = reviewRefresh?.triggered ? ' Mediainfo-Prüfung wird mit den neuen Settings automatisch neu berechnet.' : ''; toastRef.current?.show({ severity: 'success', summary: 'Settings', detail: `${Object.keys(patch).length} Änderung(en) gespeichert.${reviewRefreshHint}` }); } catch (error) { let detail = error?.message || 'Unbekannter Fehler'; if (Array.isArray(error?.details)) { const nextErrors = {}; for (const item of error.details) { if (item?.key) { nextErrors[item.key] = item.message || 'Ungültiger Wert'; } } setErrors(nextErrors); detail = 'Mindestens ein Feld ist ungültig.'; } toastRef.current?.show({ severity: 'error', summary: 'Speichern fehlgeschlagen', detail }); } finally { setSaving(false); } }; const handleDiscard = () => { setDraftValues(initialValues); setErrors({}); }; const handlePushoverTest = async () => { setTestingPushover(true); try { const response = await api.testPushover(); const sent = response?.result?.sent; if (sent) { toastRef.current?.show({ severity: 'success', summary: 'PushOver', detail: 'Testnachricht wurde versendet.' }); } else { toastRef.current?.show({ severity: 'warn', summary: 'PushOver', detail: `Nicht versendet (${response?.result?.reason || 'unbekannt'}).` }); } } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'PushOver Fehler', detail: error.message }); } finally { setTestingPushover(false); } }; const handleCoverArtRecovery = async () => { setCoverArtRecoveryBusy(true); try { const response = await api.recoverMissingCoverArt({}); const result = response?.result || {}; const recovered = Number(result?.recovered || 0); const failed = Number(result?.failed || 0); const scanned = Number(result?.scannedJobs || 0); toastRef.current?.show({ severity: failed > 0 ? 'warn' : 'success', summary: 'Coverart-Prüfung', detail: `Geprüft: ${scanned} | Nachgeladen: ${recovered} | Fehlgeschlagen: ${failed}`, life: 5000 }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Coverart-Prüfung fehlgeschlagen', detail: error.message }); } finally { setCoverArtRecoveryBusy(false); } }; const handleScriptEditorChange = (key, value) => { setScriptEditor((prev) => ({ ...prev, [key]: value })); setScriptErrors((prev) => ({ ...prev, [key]: null })); }; const clearScriptEditor = () => { setScriptEditor({ mode: 'none', id: null, name: '', scriptBody: '' }); setScriptErrors({}); }; const startCreateScript = () => { setScriptEditor({ mode: 'create', id: null, name: '', scriptBody: '' }); setScriptErrors({}); setLastScriptTestResult(null); }; const startEditScript = (script) => { setScriptEditor({ mode: 'edit', id: script?.id || null, name: script?.name || '', scriptBody: script?.scriptBody || '' }); setScriptErrors({}); setLastScriptTestResult(null); }; const handleSaveScript = async () => { if (scriptEditor?.mode !== 'create' && scriptEditor?.mode !== 'edit') { return; } const payload = { name: String(scriptEditor?.name || '').trim(), scriptBody: String(scriptEditor?.scriptBody || '') }; setScriptSaving(true); try { if (scriptEditor?.id) { await api.updateScript(scriptEditor.id, payload); toastRef.current?.show({ severity: 'success', summary: 'Scripte', detail: 'Script aktualisiert.' }); } else { await api.createScript(payload); toastRef.current?.show({ severity: 'success', summary: 'Scripte', detail: 'Script angelegt.' }); } await loadScripts({ silent: true }); setScriptErrors({}); clearScriptEditor(); } catch (error) { const details = Array.isArray(error?.details) ? error.details : []; if (details.length > 0) { const nextErrors = {}; for (const item of details) { if (item?.field) { nextErrors[item.field] = item.message || 'Ungültiger Wert'; } } setScriptErrors(nextErrors); } toastRef.current?.show({ severity: 'error', summary: 'Script speichern fehlgeschlagen', detail: error.message }); } finally { setScriptSaving(false); } }; const handleDeleteScript = async (script) => { const scriptId = Number(script?.id); if (!Number.isFinite(scriptId) || scriptId <= 0) { return; } const confirmed = await confirmModal({ header: 'Script löschen', message: `Script "${script?.name || scriptId}" wirklich löschen?`, acceptLabel: 'Löschen', rejectLabel: 'Abbrechen', danger: true }); if (!confirmed) { return; } setScriptActionBusyId(scriptId); try { await api.deleteScript(scriptId); toastRef.current?.show({ severity: 'success', summary: 'Scripte', detail: 'Script gelöscht.' }); await loadScripts({ silent: true }); if (scriptEditor?.mode === 'edit' && Number(scriptEditor?.id) === scriptId) { clearScriptEditor(); } } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Script löschen fehlgeschlagen', detail: error.message }); } finally { setScriptActionBusyId(null); } }; const handleTestScript = async (script) => { const scriptId = Number(script?.id); if (!Number.isFinite(scriptId) || scriptId <= 0) { return; } setScriptActionBusyId(scriptId); try { const response = await api.testScript(scriptId); const result = response?.result || null; setLastScriptTestResult(result); if (result?.success) { toastRef.current?.show({ severity: 'success', summary: 'Script-Test', detail: `"${script?.name || scriptId}" erfolgreich ausgeführt.` }); } else { toastRef.current?.show({ severity: 'warn', summary: 'Script-Test', detail: `"${script?.name || scriptId}" fehlgeschlagen (exit=${result?.exitCode ?? 'n/a'}).` }); } } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Script-Test fehlgeschlagen', detail: error.message }); } finally { setScriptActionBusyId(null); } }; const handleScriptListDragStart = (event, scriptId) => { if (scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId)) { event.preventDefault(); return; } setScriptListDragSourceId(Number(scriptId)); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(scriptId)); }; const handleScriptListDragOver = (event) => { const sourceId = Number(scriptListDragSourceId); if (!Number.isFinite(sourceId) || sourceId <= 0) { return; } event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }; const handleScriptListDrop = async (event, targetIndex) => { event.preventDefault(); if (scriptReordering) { setScriptListDragSourceId(null); return; } const sourceId = Number(scriptListDragSourceId); setScriptListDragSourceId(null); const { changed, next } = reorderListById(scripts, sourceId, targetIndex); if (!changed) { return; } const orderedScriptIds = next .map((script) => Number(script?.id)) .filter((id) => Number.isFinite(id) && id > 0); setScripts(next); setScriptReordering(true); try { await api.reorderScripts(orderedScriptIds); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Script-Reihenfolge', detail: error.message }); await loadScripts({ silent: true }); } finally { setScriptReordering(false); } }; const handleTestChain = async (chain) => { const chainId = Number(chain?.id); if (!Number.isFinite(chainId) || chainId <= 0) { return; } setChainActionBusyId(chainId); setLastChainTestResult(null); try { const response = await api.testScriptChain(chainId); const result = response?.result || null; setLastChainTestResult(result); if (!result?.aborted) { toastRef.current?.show({ severity: 'success', summary: 'Ketten-Test', detail: `"${chain?.name || chainId}" erfolgreich ausgeführt (${result?.succeeded ?? 0}/${result?.steps ?? 0} Schritte).` }); } else { toastRef.current?.show({ severity: 'warn', summary: 'Ketten-Test', detail: `"${chain?.name || chainId}" abgebrochen (${result?.succeeded ?? 0}/${result?.steps ?? 0} Schritte OK).` }); } } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Ketten-Test fehlgeschlagen', detail: error.message }); } finally { setChainActionBusyId(null); } }; // Chain editor handlers const openChainEditor = (chain = null) => { if (chain) { setChainEditor({ open: true, id: chain.id, name: chain.name, steps: (chain.steps || []).map((s, i) => ({ ...s, _key: `${s.id || i}-${Date.now()}` })) }); } else { setChainEditor({ open: true, id: null, name: '', steps: [] }); } setChainEditorErrors({}); }; const closeChainEditor = () => { setChainEditor({ open: false, id: null, name: '', steps: [] }); setChainEditorErrors({}); }; const addChainStep = (stepType, scriptId = null, scriptName = null) => { setChainEditor((prev) => ({ ...prev, steps: [ ...prev.steps, { _key: `new-${Date.now()}-${Math.random()}`, stepType, scriptId: stepType === 'script' ? scriptId : null, scriptName: stepType === 'script' ? scriptName : null, waitSeconds: stepType === 'wait' ? 10 : null } ] })); }; const removeChainStep = (index) => { setChainEditor((prev) => ({ ...prev, steps: prev.steps.filter((_, i) => i !== index) })); }; const updateChainStepWait = (index, seconds) => { setChainEditor((prev) => ({ ...prev, steps: prev.steps.map((s, i) => i === index ? { ...s, waitSeconds: seconds } : s) })); }; const moveChainStep = (fromIndex, toIndex) => { if (fromIndex === toIndex) { return; } setChainEditor((prev) => { const steps = [...prev.steps]; const [moved] = steps.splice(fromIndex, 1); steps.splice(toIndex, 0, moved); return { ...prev, steps }; }); }; const handleSaveChain = async () => { const name = String(chainEditor.name || '').trim(); if (!name) { setChainEditorErrors({ name: 'Name darf nicht leer sein.' }); return; } const payload = { name, steps: chainEditor.steps.map((s) => ({ stepType: s.stepType, scriptId: s.stepType === 'script' ? s.scriptId : null, waitSeconds: s.stepType === 'wait' ? Number(s.waitSeconds || 10) : null })) }; setChainSaving(true); try { if (chainEditor.id) { await api.updateScriptChain(chainEditor.id, payload); toastRef.current?.show({ severity: 'success', summary: 'Skriptkette', detail: 'Kette aktualisiert.' }); } else { await api.createScriptChain(payload); toastRef.current?.show({ severity: 'success', summary: 'Skriptkette', detail: 'Kette angelegt.' }); } await loadChains({ silent: true }); closeChainEditor(); } catch (error) { const details = Array.isArray(error?.details) ? error.details : []; if (details.length > 0) { const errs = {}; for (const item of details) { if (item?.field) { errs[item.field] = item.message || 'Ungültig'; } } setChainEditorErrors(errs); } toastRef.current?.show({ severity: 'error', summary: 'Kette speichern fehlgeschlagen', detail: error.message }); } finally { setChainSaving(false); } }; const handleDeleteChain = async (chain) => { const chainId = Number(chain?.id); if (!Number.isFinite(chainId) || chainId <= 0) { return; } const confirmed = await confirmModal({ header: 'Skriptkette löschen', message: `Skriptkette "${chain?.name || chainId}" wirklich löschen?`, acceptLabel: 'Löschen', rejectLabel: 'Abbrechen', danger: true }); if (!confirmed) { return; } try { await api.deleteScriptChain(chainId); toastRef.current?.show({ severity: 'success', summary: 'Skriptketten', detail: 'Kette gelöscht.' }); await loadChains({ silent: true }); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Kette löschen fehlgeschlagen', detail: error.message }); } }; const handleChainListDragStart = (event, chainId) => { if (chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId)) { event.preventDefault(); return; } setChainListDragSourceId(Number(chainId)); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(chainId)); }; const handleChainListDragOver = (event) => { const sourceId = Number(chainListDragSourceId); if (!Number.isFinite(sourceId) || sourceId <= 0) { return; } event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }; const handleChainListDrop = async (event, targetIndex) => { event.preventDefault(); if (chainReordering) { setChainListDragSourceId(null); return; } const sourceId = Number(chainListDragSourceId); setChainListDragSourceId(null); const { changed, next } = reorderListById(chains, sourceId, targetIndex); if (!changed) { return; } const orderedChainIds = next .map((chain) => Number(chain?.id)) .filter((id) => Number.isFinite(id) && id > 0); setChains(next); setChainReordering(true); try { await api.reorderScriptChains(orderedChainIds); } catch (error) { toastRef.current?.show({ severity: 'error', summary: 'Ketten-Reihenfolge', detail: error.message }); await loadChains({ silent: true }); } finally { setChainReordering(false); } }; // Chain DnD handlers const handleChainPaletteDragStart = (event, data) => { setChainDragSource({ origin: 'palette', ...data }); event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData('text/plain', JSON.stringify(data)); }; const handleChainStepDragStart = (event, index) => { setChainDragSource({ origin: 'step', index }); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(index)); }; const handleChainDropzoneDrop = (event, targetIndex) => { event.preventDefault(); if (!chainDragSource) { return; } if (chainDragSource.origin === 'palette') { const newStep = { _key: `new-${Date.now()}-${Math.random()}`, stepType: chainDragSource.stepType, scriptId: chainDragSource.stepType === 'script' ? chainDragSource.scriptId : null, scriptName: chainDragSource.stepType === 'script' ? chainDragSource.scriptName : null, waitSeconds: chainDragSource.stepType === 'wait' ? 10 : null }; setChainEditor((prev) => { const steps = [...prev.steps]; const insertAt = targetIndex != null ? targetIndex : steps.length; steps.splice(insertAt, 0, newStep); return { ...prev, steps }; }); } else if (chainDragSource.origin === 'step') { moveChainStep(chainDragSource.index, targetIndex != null ? targetIndex : chainEditor.steps.length - 1); } setChainDragSource(null); }; const handleChainDragOver = (event) => { event.preventDefault(); event.dataTransfer.dropEffect = chainDragSource?.origin === 'palette' ? 'copy' : 'move'; }; const scriptListDnDDisabled = scriptSaving || scriptsLoading || scriptReordering || scriptEditor?.mode === 'create' || Boolean(scriptActionBusyId); const chainListDnDDisabled = chainSaving || chainsLoading || chainReordering || Boolean(chainActionBusyId); return (
Lade Settings ...
) : (Lade Scripts ...
) : (Keine Scripts vorhanden.
) : ({`${lastScriptTestResult.stdout || ''}${lastScriptTestResult.stderr ? `\n${lastScriptTestResult.stderr}` : ''}`.trim() || 'Keine Ausgabe.'}
Lade Skriptketten...
) : chains.length === 0 ? (Keine Skriptketten vorhanden.
) : ({`${step.stdout || ''}${step.stderr ? `\n${step.stderr}` : ''}`.trim()}
) : null}