From cc08bb1c19777bf78baa2fa10be5401ed61c7839 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Feb 2026 15:38:40 +0300 Subject: [PATCH] Add clone support for all entity types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clone button on every card opens the editor in create mode pre-filled with copied data and a "(Copy)" name suffix. Cancelling discards the clone — entity is only persisted on Save. Supported: LED targets, color strip sources, KC targets, pattern templates, picture sources, capture templates, PP templates. Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/static/js/app.js | 12 ++ .../static/js/features/color-strips.js | 55 ++++++--- .../static/js/features/kc-targets.js | 33 +++++- .../static/js/features/pattern-templates.js | 21 +++- .../static/js/features/streams.js | 105 ++++++++++++++++-- .../static/js/features/targets.js | 39 ++++++- .../wled_controller/static/locales/en.json | 1 + .../wled_controller/static/locales/ru.json | 1 + 8 files changed, 238 insertions(+), 29 deletions(-) diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 394cb99..e385e43 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -53,12 +53,14 @@ import { showAddPPTemplateModal, editPPTemplate, closePPTemplateModal, savePPTemplate, deletePPTemplate, addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption, renderModalFilterList, updateCaptureDuration, + cloneStream, cloneCaptureTemplate, clonePPTemplate, } from './features/streams.js'; import { createKCTargetCard, testKCTarget, toggleKCTestAutoRefresh, showKCEditor, closeKCEditorModal, forceCloseKCEditorModal, saveKCEditor, deleteKCTarget, disconnectAllKCWebSockets, updateKCBrightnessLabel, saveKCBrightness, + cloneKCTarget, } from './features/kc-targets.js'; import { createPatternTemplateCard, @@ -67,6 +69,7 @@ import { renderPatternRectList, selectPatternRect, updatePatternRect, addPatternRect, deleteSelectedPatternRect, removePatternRect, capturePatternBackground, + clonePatternTemplate, } from './features/pattern-templates.js'; import { loadProfiles, openProfileEditor, closeProfileEditorModal, @@ -85,6 +88,7 @@ import { addTargetSegment, removeTargetSegment, startTargetProcessing, stopTargetProcessing, startTargetOverlay, stopTargetOverlay, deleteTarget, + cloneTarget, } from './features/targets.js'; // Layer 5: color-strip sources @@ -95,6 +99,7 @@ import { compositeAddLayer, compositeRemoveLayer, onAudioVizChange, applyGradientPreset, + cloneColorStrip, } from './features/color-strips.js'; // Layer 5: calibration @@ -210,6 +215,9 @@ Object.assign(window, { moveFilter, updateFilterOption, renderModalFilterList, + cloneStream, + cloneCaptureTemplate, + clonePPTemplate, // kc-targets createKCTargetCard, @@ -223,6 +231,7 @@ Object.assign(window, { disconnectAllKCWebSockets, updateKCBrightnessLabel, saveKCBrightness, + cloneKCTarget, // pattern-templates createPatternTemplateCard, @@ -238,6 +247,7 @@ Object.assign(window, { deleteSelectedPatternRect, removePatternRect, capturePatternBackground, + clonePatternTemplate, // profiles loadProfiles, @@ -273,6 +283,7 @@ Object.assign(window, { startTargetOverlay, stopTargetOverlay, deleteTarget, + cloneTarget, // color-strip sources showCSSEditor, @@ -290,6 +301,7 @@ Object.assign(window, { compositeRemoveLayer, onAudioVizChange, applyGradientPreset, + cloneColorStrip, // calibration showCalibration, diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 202df35..f732a38 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -584,6 +584,7 @@ export function createColorStripCard(source, pictureSourceMap) { ${propsHtml}
+ ${calibrationBtn}
@@ -593,7 +594,7 @@ export function createColorStripCard(source, pictureSourceMap) { /* ── Editor open/close ────────────────────────────────────────── */ -export async function showCSSEditor(cssId = null) { +export async function showCSSEditor(cssId = null, cloneData = null) { try { const sourcesResp = await fetchWithAuth('/picture-sources'); const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : []; @@ -616,14 +617,8 @@ export async function showCSSEditor(cssId = null) { sourceSelect.appendChild(opt); }); - if (cssId) { - const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`); - if (!resp.ok) throw new Error('Failed to load color strip source'); - const css = await resp.json(); - - document.getElementById('css-editor-id').value = css.id; - document.getElementById('css-editor-name').value = css.name; - + // Helper: populate editor fields from a CSS source object + const _populateFromCSS = async (css) => { const sourceType = css.source_type || 'picture'; document.getElementById('css-editor-type').value = sourceType; onCSSTypeChange(); @@ -656,10 +651,6 @@ export async function showCSSEditor(cssId = null) { await _loadAudioDevices(); _loadAudioState(css); } else if (sourceType === 'composite') { - // Exclude self from available sources when editing - _compositeAvailableSources = allCssSources.filter(s => - s.source_type !== 'composite' && s.id !== css.id - ); _loadCompositeState(css); } else { sourceSelect.value = css.picture_source_id || ''; @@ -686,7 +677,30 @@ export async function showCSSEditor(cssId = null) { } document.getElementById('css-editor-led-count').value = css.led_count ?? 0; + }; + + if (cssId) { + const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`); + if (!resp.ok) throw new Error('Failed to load color strip source'); + const css = await resp.json(); + + document.getElementById('css-editor-id').value = css.id; + document.getElementById('css-editor-name').value = css.name; + + // Exclude self from composite sources when editing + if (css.source_type === 'composite') { + _compositeAvailableSources = allCssSources.filter(s => + s.source_type !== 'composite' && s.id !== css.id + ); + } + + await _populateFromCSS(css); document.getElementById('css-editor-title').textContent = t('color_strip.edit'); + } else if (cloneData) { + document.getElementById('css-editor-id').value = ''; + document.getElementById('css-editor-name').value = (cloneData.name || '') + ' (Copy)'; + await _populateFromCSS(cloneData); + document.getElementById('css-editor-title').textContent = t('color_strip.add'); } else { document.getElementById('css-editor-id').value = ''; document.getElementById('css-editor-name').value = ''; @@ -882,6 +896,21 @@ export async function saveCSSEditor() { } } +/* ── Clone ────────────────────────────────────────────────────── */ + +export async function cloneColorStrip(cssId) { + try { + const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`); + if (!resp.ok) throw new Error('Failed to load color strip source'); + const css = await resp.json(); + showCSSEditor(null, css); + } catch (error) { + if (error.isAuth) return; + console.error('Failed to clone color strip:', error); + showToast('Failed to clone color strip source', 'error'); + } +} + /* ── Delete ───────────────────────────────────────────────────── */ export async function deleteColorStrip(cssId) { diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index fd6f7aa..004d289 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -148,6 +148,9 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) { + @@ -345,7 +348,7 @@ function _autoGenerateKCName() { document.getElementById('kc-editor-name').value = `${sourceName} \u00b7 ${patName} (${modeName})`; } -export async function showKCEditor(targetId = null) { +export async function showKCEditor(targetId = null, cloneData = null) { try { // Load sources and pattern templates in parallel const [sourcesResp, patResp] = await Promise.all([ @@ -395,6 +398,18 @@ export async function showKCEditor(targetId = null) { document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; patSelect.value = kcSettings.pattern_template_id || ''; document.getElementById('kc-editor-title').textContent = t('kc.edit'); + } else if (cloneData) { + const kcSettings = cloneData.key_colors_settings || {}; + document.getElementById('kc-editor-id').value = ''; + document.getElementById('kc-editor-name').value = (cloneData.name || '') + ' (Copy)'; + sourceSelect.value = cloneData.picture_source_id || ''; + document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10; + document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10; + document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average'; + document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3; + document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; + patSelect.value = kcSettings.pattern_template_id || ''; + document.getElementById('kc-editor-title').textContent = t('kc.add'); } else { document.getElementById('kc-editor-id').value = ''; document.getElementById('kc-editor-name').value = ''; @@ -409,12 +424,12 @@ export async function showKCEditor(targetId = null) { } // Auto-name - set_kcNameManuallyEdited(!!targetId); + set_kcNameManuallyEdited(!!(targetId || cloneData)); document.getElementById('kc-editor-name').oninput = () => { set_kcNameManuallyEdited(true); }; sourceSelect.onchange = () => _autoGenerateKCName(); document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName(); patSelect.onchange = () => _autoGenerateKCName(); - if (!targetId) _autoGenerateKCName(); + if (!targetId && !cloneData) _autoGenerateKCName(); kcEditorModal.snapshot(); kcEditorModal.open(); @@ -502,6 +517,18 @@ export async function saveKCEditor() { } } +export async function cloneKCTarget(targetId) { + try { + const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load target'); + const target = await resp.json(); + showKCEditor(null, target); + } catch (error) { + console.error('Failed to clone KC target:', error); + showToast('Failed to clone key colors target', 'error'); + } +} + export async function deleteKCTarget(targetId) { const confirmed = await showConfirm(t('kc.delete.confirm')); if (!confirmed) return; diff --git a/server/src/wled_controller/static/js/features/pattern-templates.js b/server/src/wled_controller/static/js/features/pattern-templates.js index 70d73a6..ad494cf 100644 --- a/server/src/wled_controller/static/js/features/pattern-templates.js +++ b/server/src/wled_controller/static/js/features/pattern-templates.js @@ -62,13 +62,14 @@ export function createPatternTemplateCard(pt) { ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}
+
`; } -export async function showPatternTemplateEditor(templateId = null) { +export async function showPatternTemplateEditor(templateId = null, cloneData = null) { try { // Load sources for background capture const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null); @@ -98,6 +99,12 @@ export async function showPatternTemplateEditor(templateId = null) { document.getElementById('pattern-template-description').value = tmpl.description || ''; document.getElementById('pattern-template-modal-title').textContent = t('pattern.edit'); setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r }))); + } else if (cloneData) { + document.getElementById('pattern-template-id').value = ''; + document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)'; + document.getElementById('pattern-template-description').value = cloneData.description || ''; + document.getElementById('pattern-template-modal-title').textContent = t('pattern.add'); + setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r }))); } else { document.getElementById('pattern-template-id').value = ''; document.getElementById('pattern-template-name').value = ''; @@ -180,6 +187,18 @@ export async function savePatternTemplate() { } } +export async function clonePatternTemplate(templateId) { + try { + const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load pattern template'); + const tmpl = await resp.json(); + showPatternTemplateEditor(null, tmpl); + } catch (error) { + console.error('Failed to clone pattern template:', error); + showToast('Failed to clone pattern template', 'error'); + } +} + export async function deletePatternTemplate(templateId) { const confirmed = await showConfirm(t('pattern.delete.confirm')); if (!confirmed) return; diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 5b51c3a..986ddf1 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -56,7 +56,7 @@ function getEngineIcon(engineType) { return '🚀'; } -export async function showAddTemplateModal() { +export async function showAddTemplateModal(cloneData = null) { setCurrentEditingTemplateId(null); document.getElementById('template-modal-title').textContent = t('templates.add'); document.getElementById('template-form').reset(); @@ -64,11 +64,20 @@ export async function showAddTemplateModal() { document.getElementById('engine-config-section').style.display = 'none'; document.getElementById('template-error').style.display = 'none'; - set_templateNameManuallyEdited(false); + set_templateNameManuallyEdited(!!cloneData); document.getElementById('template-name').oninput = () => { set_templateNameManuallyEdited(true); }; await loadAvailableEngines(); + // Pre-fill from clone data after engines are loaded + if (cloneData) { + document.getElementById('template-name').value = (cloneData.name || '') + ' (Copy)'; + document.getElementById('template-description').value = cloneData.description || ''; + document.getElementById('template-engine').value = cloneData.engine_type; + await onEngineChange(); + populateEngineConfig(cloneData.engine_config); + } + templateModal.open(); } @@ -509,6 +518,7 @@ function renderPictureSourcesList(streams) { ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}
+
@@ -544,6 +554,7 @@ function renderPictureSourcesList(streams) { ` : ''}
+
@@ -566,6 +577,7 @@ function renderPictureSourcesList(streams) { ${filterChainHtml}
+
@@ -688,8 +700,8 @@ function _autoGenerateStreamName() { } } -export async function showAddStreamModal(presetType) { - const streamType = presetType || 'raw'; +export async function showAddStreamModal(presetType, cloneData = null) { + const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw'; const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' }; document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add'); document.getElementById('stream-form').reset(); @@ -708,7 +720,7 @@ export async function showAddStreamModal(presetType) { imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0); onStreamTypeChange(); - set_streamNameManuallyEdited(false); + set_streamNameManuallyEdited(!!cloneData); document.getElementById('stream-name').oninput = () => { set_streamNameManuallyEdited(true); }; document.getElementById('stream-capture-template').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-source').onchange = () => _autoGenerateStreamName(); @@ -716,6 +728,27 @@ export async function showAddStreamModal(presetType) { await populateStreamModalDropdowns(); + // Pre-fill from clone data after dropdowns are populated + if (cloneData) { + document.getElementById('stream-name').value = (cloneData.name || '') + ' (Copy)'; + document.getElementById('stream-description').value = cloneData.description || ''; + if (streamType === 'raw') { + const displayIdx = cloneData.display_index ?? 0; + const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null; + onStreamDisplaySelected(displayIdx, display); + document.getElementById('stream-capture-template').value = cloneData.capture_template_id || ''; + const fps = cloneData.target_fps ?? 30; + document.getElementById('stream-target-fps').value = fps; + document.getElementById('stream-target-fps-value').textContent = fps; + } else if (streamType === 'processed') { + document.getElementById('stream-source').value = cloneData.source_stream_id || ''; + document.getElementById('stream-pp-template').value = cloneData.postprocessing_template_id || ''; + } else if (streamType === 'static_image') { + document.getElementById('stream-image-source').value = cloneData.image_source || ''; + if (cloneData.image_source) validateStaticImage(); + } + } + streamModal.open(); } @@ -1293,7 +1326,7 @@ function _autoGeneratePPTemplateName() { } } -export async function showAddPPTemplateModal() { +export async function showAddPPTemplateModal(cloneData = null) { if (_availableFilters.length === 0) await loadAvailableFilters(); document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add'); @@ -1301,14 +1334,27 @@ export async function showAddPPTemplateModal() { document.getElementById('pp-template-id').value = ''; document.getElementById('pp-template-error').style.display = 'none'; - set_modalFilters([]); - - set_ppTemplateNameManuallyEdited(false); + if (cloneData) { + set_modalFilters((cloneData.filters || []).map(fi => ({ + filter_id: fi.filter_id, + options: { ...fi.options }, + }))); + set_ppTemplateNameManuallyEdited(true); + } else { + set_modalFilters([]); + set_ppTemplateNameManuallyEdited(false); + } document.getElementById('pp-template-name').oninput = () => { set_ppTemplateNameManuallyEdited(true); }; _populateFilterSelect(); renderModalFilterList(); + // Pre-fill from clone data after form is set up + if (cloneData) { + document.getElementById('pp-template-name').value = (cloneData.name || '') + ' (Copy)'; + document.getElementById('pp-template-description').value = cloneData.description || ''; + } + ppTemplateModal.open(); } @@ -1374,6 +1420,47 @@ export async function savePPTemplate() { } } +// ===== Clone functions ===== + +export async function cloneStream(streamId) { + try { + const resp = await fetchWithAuth(`/picture-sources/${streamId}`); + if (!resp.ok) throw new Error('Failed to load stream'); + const stream = await resp.json(); + showAddStreamModal(stream.stream_type, stream); + } catch (error) { + if (error.isAuth) return; + console.error('Failed to clone stream:', error); + showToast('Failed to clone picture source', 'error'); + } +} + +export async function cloneCaptureTemplate(templateId) { + try { + const resp = await fetchWithAuth(`/capture-templates/${templateId}`); + if (!resp.ok) throw new Error('Failed to load template'); + const tmpl = await resp.json(); + showAddTemplateModal(tmpl); + } catch (error) { + if (error.isAuth) return; + console.error('Failed to clone capture template:', error); + showToast('Failed to clone capture template', 'error'); + } +} + +export async function clonePPTemplate(templateId) { + try { + const resp = await fetchWithAuth(`/postprocessing-templates/${templateId}`); + if (!resp.ok) throw new Error('Failed to load template'); + const tmpl = await resp.json(); + showAddPPTemplateModal(tmpl); + } catch (error) { + if (error.isAuth) return; + console.error('Failed to clone PP template:', error); + showToast('Failed to clone postprocessing template', 'error'); + } +} + export async function deletePPTemplate(templateId) { const confirmed = await showConfirm(t('postprocessing.delete.confirm')); if (!confirmed) return; diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 73c5d66..0c05b69 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -210,7 +210,7 @@ function _renderSegmentRowInner(index, segment) { `; } -export async function showTargetEditor(targetId = null) { +export async function showTargetEditor(targetId = null, cloneData = null) { try { // Load devices and CSS sources for dropdowns const [devicesResp, cssResp] = await Promise.all([ @@ -263,6 +263,24 @@ export async function showTargetEditor(targetId = null) { } else { segments.forEach(seg => addTargetSegment(seg)); } + } else if (cloneData) { + // Cloning — create mode but pre-filled from clone data + document.getElementById('target-editor-id').value = ''; + document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)'; + deviceSelect.value = cloneData.device_id || ''; + const fps = cloneData.fps ?? 30; + document.getElementById('target-editor-fps').value = fps; + document.getElementById('target-editor-fps-value').textContent = fps; + document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0; + document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0; + document.getElementById('target-editor-title').textContent = t('targets.add'); + + const segments = cloneData.segments || []; + if (segments.length === 0) { + addTargetSegment(); + } else { + segments.forEach(seg => addTargetSegment(seg)); + } } else { // Creating new target — start with one empty segment document.getElementById('target-editor-id').value = ''; @@ -276,11 +294,11 @@ export async function showTargetEditor(targetId = null) { } // Auto-name generation - _targetNameManuallyEdited = !!targetId; + _targetNameManuallyEdited = !!(targetId || cloneData); document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; window._targetAutoName = _autoGenerateTargetName; deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; - if (!targetId) _autoGenerateTargetName(); + if (!targetId && !cloneData) _autoGenerateTargetName(); // Show/hide standby interval based on selected device capabilities _updateDeviceInfo(); @@ -735,6 +753,9 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) { ▶️ `} + @@ -819,6 +840,18 @@ export async function stopTargetOverlay(targetId) { }); } +export async function cloneTarget(targetId) { + try { + const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() }); + if (!resp.ok) throw new Error('Failed to load target'); + const target = await resp.json(); + showTargetEditor(null, target); + } catch (error) { + console.error('Failed to clone target:', error); + showToast('Failed to clone target', 'error'); + } +} + export async function deleteTarget(targetId) { const confirmed = await showConfirm(t('targets.delete.confirm')); if (!confirmed) return; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 459b54d..4d8b63e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -237,6 +237,7 @@ "common.loading": "Loading...", "common.delete": "Delete", "common.edit": "Edit", + "common.clone": "Clone", "streams.title": "\uD83D\uDCFA Sources", "streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.", "streams.group.raw": "Screen Capture", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index a2f47f2..274cfb6 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -237,6 +237,7 @@ "common.loading": "Загрузка...", "common.delete": "Удалить", "common.edit": "Редактировать", + "common.clone": "Клонировать", "streams.title": "\uD83D\uDCFA Источники", "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", "streams.group.raw": "Захват Экрана",