6a6c8b2c52
Lint & Test / test (push) Failing after 19s
- Replace winsdk (~35MB) with winrt packages (~2.5MB) for OS notification listener. API is identical, 93% size reduction. - Replace wmi (~3-5MB) with ctypes for monitor names (EnumDisplayDevicesW) and camera names (SetupAPI). Zero external dependency. - Migrate cv2.resize/imencode/LUT to Pillow/numpy in 5 files (filters, preview helpers, kc_target_processor). OpenCV only needed for camera and video stream now. - Fix DefWindowProcW ctypes overflow on 64-bit Python (pre-existing bug in platform_detector display power listener). - Fix openLightbox import in streams-capture-templates.ts (was using broken window cast instead of direct import). - Add mandatory data migration policy to CLAUDE.md after silent data loss incident from storage file rename without migration.
586 lines
24 KiB
TypeScript
586 lines
24 KiB
TypeScript
/**
|
|
* Streams — Capture template CRUD, engine config, test modal.
|
|
* Extracted from streams.ts to reduce file size.
|
|
*/
|
|
|
|
import {
|
|
availableEngines, setAvailableEngines,
|
|
currentEditingTemplateId, setCurrentEditingTemplateId,
|
|
_templateNameManuallyEdited, set_templateNameManuallyEdited,
|
|
currentTestingTemplate, setCurrentTestingTemplate,
|
|
_cachedStreams, _cachedDisplays,
|
|
captureTemplatesCache, displaysCache,
|
|
apiKey,
|
|
} from '../core/state.ts';
|
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
|
import { t } from '../core/i18n.ts';
|
|
import { Modal } from '../core/modal.ts';
|
|
import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts';
|
|
import { openDisplayPicker, formatDisplayLabel } from './displays.ts';
|
|
import {
|
|
getEngineIcon,
|
|
ICON_CAPTURE_TEMPLATE,
|
|
} from '../core/icons.ts';
|
|
import * as P from '../core/icon-paths.ts';
|
|
import { TagInput } from '../core/tag-input.ts';
|
|
import { IconSelect } from '../core/icon-select.ts';
|
|
import { loadPictureSources } from './streams.ts';
|
|
|
|
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
|
|
|
// ── TagInput instance for capture template modal ──
|
|
let _captureTemplateTagsInput: TagInput | null = null;
|
|
|
|
class CaptureTemplateModal extends Modal {
|
|
constructor() { super('template-modal'); }
|
|
|
|
snapshotValues() {
|
|
const vals: any = {
|
|
name: (document.getElementById('template-name') as HTMLInputElement).value,
|
|
description: (document.getElementById('template-description') as HTMLInputElement).value,
|
|
engine: (document.getElementById('template-engine') as HTMLSelectElement).value,
|
|
tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []),
|
|
};
|
|
document.querySelectorAll('[data-config-key]').forEach((field: any) => {
|
|
vals['cfg_' + field.dataset.configKey] = field.value;
|
|
});
|
|
return vals;
|
|
}
|
|
|
|
onForceClose() {
|
|
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
|
setCurrentEditingTemplateId(null);
|
|
set_templateNameManuallyEdited(false);
|
|
}
|
|
}
|
|
|
|
const templateModal = new CaptureTemplateModal();
|
|
const testTemplateModal = new Modal('test-template-modal');
|
|
|
|
// ===== Capture Templates =====
|
|
|
|
async function loadCaptureTemplates() {
|
|
try {
|
|
await captureTemplatesCache.fetch();
|
|
await loadPictureSources();
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
console.error('Error loading capture templates:', error);
|
|
showToast(t('streams.error.load'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function showAddTemplateModal(cloneData: any = null) {
|
|
setCurrentEditingTemplateId(null);
|
|
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.add')}`;
|
|
(document.getElementById('template-form') as HTMLFormElement).reset();
|
|
(document.getElementById('template-id') as HTMLInputElement).value = '';
|
|
document.getElementById('engine-config-section')!.style.display = 'none';
|
|
document.getElementById('template-error')!.style.display = 'none';
|
|
|
|
set_templateNameManuallyEdited(!!cloneData);
|
|
(document.getElementById('template-name') as HTMLInputElement).oninput = () => { set_templateNameManuallyEdited(true); };
|
|
|
|
await loadAvailableEngines();
|
|
|
|
// Pre-fill from clone data after engines are loaded
|
|
if (cloneData) {
|
|
(document.getElementById('template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
|
|
(document.getElementById('template-description') as HTMLInputElement).value = cloneData.description || '';
|
|
(document.getElementById('template-engine') as HTMLSelectElement).value = cloneData.engine_type;
|
|
await onEngineChange();
|
|
populateEngineConfig(cloneData.engine_config);
|
|
}
|
|
|
|
// Tags
|
|
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
|
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
|
|
_captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
|
|
|
templateModal.open();
|
|
templateModal.snapshot();
|
|
}
|
|
|
|
export async function editTemplate(templateId: any) {
|
|
try {
|
|
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
|
|
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
|
const template = await response.json();
|
|
|
|
setCurrentEditingTemplateId(templateId);
|
|
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
|
|
(document.getElementById('template-id') as HTMLInputElement).value = templateId;
|
|
(document.getElementById('template-name') as HTMLInputElement).value = template.name;
|
|
(document.getElementById('template-description') as HTMLInputElement).value = template.description || '';
|
|
|
|
await loadAvailableEngines();
|
|
(document.getElementById('template-engine') as HTMLSelectElement).value = template.engine_type;
|
|
await onEngineChange();
|
|
populateEngineConfig(template.engine_config);
|
|
|
|
await loadDisplaysForTest();
|
|
|
|
const testResults = document.getElementById('template-test-results');
|
|
if (testResults) testResults.style.display = 'none';
|
|
document.getElementById('template-error')!.style.display = 'none';
|
|
|
|
// Tags
|
|
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
|
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
|
|
_captureTemplateTagsInput.setValue(template.tags || []);
|
|
|
|
templateModal.open();
|
|
templateModal.snapshot();
|
|
} catch (error) {
|
|
console.error('Error loading template:', error);
|
|
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
export async function closeTemplateModal() {
|
|
await templateModal.close();
|
|
}
|
|
|
|
export function updateCaptureDuration(value: any) {
|
|
document.getElementById('test-template-duration-value')!.textContent = value;
|
|
localStorage.setItem('capture_duration', value);
|
|
}
|
|
|
|
function restoreCaptureDuration() {
|
|
const savedDuration = localStorage.getItem('capture_duration');
|
|
if (savedDuration) {
|
|
const durationInput = document.getElementById('test-template-duration') as HTMLInputElement;
|
|
const durationValue = document.getElementById('test-template-duration-value')!;
|
|
durationInput.value = savedDuration;
|
|
durationValue.textContent = savedDuration;
|
|
}
|
|
}
|
|
|
|
export async function showTestTemplateModal(templateId: any) {
|
|
try {
|
|
const templates = await captureTemplatesCache.fetch();
|
|
const template = templates.find(tp => tp.id === templateId);
|
|
|
|
if (!template) {
|
|
showToast(t('templates.error.load'), 'error');
|
|
return;
|
|
}
|
|
|
|
setCurrentTestingTemplate(template);
|
|
await loadDisplaysForTest();
|
|
restoreCaptureDuration();
|
|
|
|
testTemplateModal.open();
|
|
setupBackdropClose((testTemplateModal as any).el, () => closeTestTemplateModal());
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('templates.error.load'), 'error');
|
|
}
|
|
}
|
|
|
|
export function closeTestTemplateModal() {
|
|
testTemplateModal.forceClose();
|
|
setCurrentTestingTemplate(null);
|
|
}
|
|
|
|
async function loadAvailableEngines() {
|
|
try {
|
|
const response = await fetchWithAuth('/capture-engines');
|
|
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
|
|
const data = await response.json();
|
|
setAvailableEngines(data.engines || []);
|
|
|
|
const select = document.getElementById('template-engine') as HTMLSelectElement;
|
|
select.innerHTML = '';
|
|
|
|
availableEngines.forEach((engine: any) => {
|
|
const option = document.createElement('option');
|
|
option.value = engine.type;
|
|
option.textContent = engine.name;
|
|
if (!engine.available) {
|
|
option.disabled = true;
|
|
option.textContent += ` (${t('templates.engine.unavailable')})`;
|
|
}
|
|
select.appendChild(option);
|
|
});
|
|
|
|
if (!select.value) {
|
|
const firstAvailable = availableEngines.find(e => e.available);
|
|
if (firstAvailable) select.value = firstAvailable.type;
|
|
}
|
|
|
|
// Update icon-grid selector with dynamic engine list
|
|
const items = availableEngines
|
|
.filter(e => e.available)
|
|
.map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: t(`templates.engine.${e.type}.desc`) }));
|
|
if (_engineIconSelect) { _engineIconSelect.updateItems(items); }
|
|
else { _engineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
|
|
_engineIconSelect.setValue(select.value);
|
|
} catch (error) {
|
|
console.error('Error loading engines:', error);
|
|
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
let _engineIconSelect: IconSelect | null = null;
|
|
|
|
export async function onEngineChange() {
|
|
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
|
|
if (_engineIconSelect) _engineIconSelect.setValue(engineType);
|
|
const configSection = document.getElementById('engine-config-section')!;
|
|
const configFields = document.getElementById('engine-config-fields')!;
|
|
|
|
if (!engineType) { configSection.style.display = 'none'; return; }
|
|
|
|
const engine = availableEngines.find((e: any) => e.type === engineType);
|
|
if (!engine) { configSection.style.display = 'none'; return; }
|
|
|
|
if (!_templateNameManuallyEdited && !(document.getElementById('template-id') as HTMLInputElement).value) {
|
|
(document.getElementById('template-name') as HTMLInputElement).value = engine.name || engineType;
|
|
}
|
|
|
|
const hint = document.getElementById('engine-availability-hint')!;
|
|
if (!engine.available) {
|
|
hint.textContent = t('templates.engine.unavailable.hint');
|
|
hint.style.display = 'block';
|
|
hint.style.color = 'var(--error-color)';
|
|
} else {
|
|
hint.style.display = 'none';
|
|
}
|
|
|
|
configFields.innerHTML = '';
|
|
const defaultConfig = engine.default_config || {};
|
|
|
|
// Known select options for specific config keys
|
|
const CONFIG_SELECT_OPTIONS = {
|
|
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
|
|
};
|
|
|
|
// IconSelect definitions for specific config keys
|
|
const CONFIG_ICON_SELECT = {
|
|
camera_backend: {
|
|
columns: 2,
|
|
items: [
|
|
{ value: 'auto', icon: _icon(P.refreshCw), label: 'Auto', desc: t('templates.config.camera_backend.auto') },
|
|
{ value: 'dshow', icon: _icon(P.camera), label: 'DShow', desc: t('templates.config.camera_backend.dshow') },
|
|
{ value: 'msmf', icon: _icon(P.film), label: 'MSMF', desc: t('templates.config.camera_backend.msmf') },
|
|
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
|
|
],
|
|
},
|
|
};
|
|
|
|
if (Object.keys(defaultConfig).length === 0) {
|
|
configSection.style.display = 'none';
|
|
return;
|
|
} else {
|
|
let gridHtml = '<div class="config-grid">';
|
|
Object.entries(defaultConfig).forEach(([key, value]) => {
|
|
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
|
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
|
const selectOptions = CONFIG_SELECT_OPTIONS[key];
|
|
gridHtml += `
|
|
<label class="config-grid-label" for="config-${key}">${key}</label>
|
|
<div class="config-grid-value">
|
|
${typeof value === 'boolean' ? `
|
|
<select id="config-${key}" data-config-key="${key}">
|
|
<option value="true" ${value ? 'selected' : ''}>true</option>
|
|
<option value="false" ${!value ? 'selected' : ''}>false</option>
|
|
</select>
|
|
` : selectOptions ? `
|
|
<select id="config-${key}" data-config-key="${key}">
|
|
${selectOptions.map(opt => `<option value="${opt}" ${opt === String(value) ? 'selected' : ''}>${opt}</option>`).join('')}
|
|
</select>
|
|
` : `
|
|
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
|
|
`}
|
|
</div>
|
|
`;
|
|
});
|
|
gridHtml += '</div>';
|
|
configFields.innerHTML = gridHtml;
|
|
|
|
// Apply IconSelect to known config selects
|
|
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
|
|
const sel = document.getElementById(`config-${key}`);
|
|
if (sel) new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
|
|
}
|
|
}
|
|
|
|
configSection.style.display = 'block';
|
|
}
|
|
|
|
function populateEngineConfig(config: any) {
|
|
Object.entries(config).forEach(([key, value]: [string, any]) => {
|
|
const field = document.getElementById(`config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
|
|
if (field) {
|
|
if (field.tagName === 'SELECT') {
|
|
field.value = value.toString();
|
|
} else {
|
|
field.value = value;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function collectEngineConfig() {
|
|
const config: any = {};
|
|
const fields = document.querySelectorAll('[data-config-key]');
|
|
fields.forEach((field: any) => {
|
|
const key = field.dataset.configKey;
|
|
let value: any = field.value;
|
|
if (field.type === 'number') {
|
|
value = parseFloat(value);
|
|
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
|
|
value = value === 'true';
|
|
}
|
|
config[key] = value;
|
|
});
|
|
return config;
|
|
}
|
|
|
|
async function loadDisplaysForTest() {
|
|
try {
|
|
// Use engine-specific display list for engines with own devices (camera, scrcpy)
|
|
const engineType = currentTestingTemplate?.engine_type;
|
|
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
|
|
const url = engineHasOwnDisplays
|
|
? `/config/displays?engine_type=${engineType}`
|
|
: '/config/displays';
|
|
|
|
// Always refetch for engines with own displays (devices may change); use cache for desktop
|
|
if (!_cachedDisplays || engineHasOwnDisplays) {
|
|
const response = await fetchWithAuth(url);
|
|
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
|
|
const displaysData = await response.json();
|
|
displaysCache.update(displaysData.displays || []);
|
|
}
|
|
|
|
let selectedIndex: number | null = null;
|
|
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
|
|
|
|
if (lastDisplay !== null && _cachedDisplays) {
|
|
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
|
|
if (found) selectedIndex = found.index;
|
|
}
|
|
|
|
if (selectedIndex === null && _cachedDisplays) {
|
|
const primary = _cachedDisplays.find(d => d.is_primary);
|
|
if (primary) selectedIndex = primary.index;
|
|
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
|
|
}
|
|
|
|
if (selectedIndex !== null && _cachedDisplays) {
|
|
const display = _cachedDisplays.find(d => d.index === selectedIndex);
|
|
(window as any).onTestDisplaySelected(selectedIndex, display);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading displays:', error);
|
|
}
|
|
}
|
|
|
|
export function runTemplateTest() {
|
|
if (!currentTestingTemplate) {
|
|
showToast(t('templates.test.error.no_engine'), 'error');
|
|
return;
|
|
}
|
|
|
|
const displayIndex = (document.getElementById('test-template-display') as HTMLSelectElement).value;
|
|
const captureDuration = parseFloat((document.getElementById('test-template-duration') as HTMLInputElement).value);
|
|
|
|
if (displayIndex === '') {
|
|
showToast(t('templates.test.error.no_display'), 'error');
|
|
return;
|
|
}
|
|
|
|
const template = currentTestingTemplate;
|
|
localStorage.setItem('lastTestDisplayIndex', displayIndex);
|
|
|
|
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
|
|
_runTestViaWS(
|
|
'/capture-templates/test/ws',
|
|
{},
|
|
{
|
|
engine_type: template.engine_type,
|
|
engine_config: template.engine_config,
|
|
display_index: parseInt(displayIndex),
|
|
capture_duration: captureDuration,
|
|
preview_width: previewWidth,
|
|
},
|
|
captureDuration,
|
|
);
|
|
}
|
|
|
|
function buildTestStatsHtml(result: any) {
|
|
// Support both REST format (nested) and WS format (flat)
|
|
const p = result.performance || result;
|
|
const duration = p.capture_duration_s ?? p.elapsed_s ?? 0;
|
|
const frameCount = p.frame_count ?? 0;
|
|
const fps = p.actual_fps ?? p.fps ?? 0;
|
|
const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0;
|
|
const w = result.full_capture?.width ?? result.width ?? 0;
|
|
const h = result.full_capture?.height ?? result.height ?? 0;
|
|
const res = `${w}x${h}`;
|
|
|
|
let html = `
|
|
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
|
|
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
|
|
if (frameCount > 1) {
|
|
html += `
|
|
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
|
|
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
|
|
}
|
|
html += `
|
|
<div class="stat-item"><span>${t('templates.test.results.resolution')}</span> <strong>${res}</strong></div>`;
|
|
return html;
|
|
}
|
|
|
|
// ===== Shared WebSocket test helper =====
|
|
|
|
/**
|
|
* Run a capture test via WebSocket, streaming intermediate previews into
|
|
* the overlay spinner and opening the lightbox with the final result.
|
|
*
|
|
* @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws')
|
|
* @param {Object} queryParams Extra query params (duration, source_stream_id, etc.)
|
|
* @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test)
|
|
* @param {number} duration Test duration for overlay progress ring
|
|
*/
|
|
export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessage: any = null, duration = 5) {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
// Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px
|
|
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
|
|
const params = new URLSearchParams({ token: apiKey, preview_width: String(previewWidth), ...queryParams });
|
|
const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`;
|
|
|
|
showOverlaySpinner(t('streams.test.running'), duration);
|
|
|
|
let gotResult = false;
|
|
let ws;
|
|
|
|
try {
|
|
ws = new WebSocket(wsUrl);
|
|
} catch (e) {
|
|
hideOverlaySpinner();
|
|
showToast(t('streams.test.error.failed') + ': ' + e.message, 'error');
|
|
return;
|
|
}
|
|
|
|
// Close WS when user cancels overlay
|
|
const patchCloseBtn = () => {
|
|
const closeBtn = document.querySelector('.overlay-spinner-close') as HTMLElement | null;
|
|
if (closeBtn) {
|
|
const origHandler = closeBtn.onclick;
|
|
closeBtn.onclick = () => {
|
|
if (ws.readyState <= WebSocket.OPEN) ws.close();
|
|
if (origHandler) (origHandler as any)();
|
|
};
|
|
}
|
|
};
|
|
patchCloseBtn();
|
|
|
|
// Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts)
|
|
const origAbort = window._overlayAbortController;
|
|
if (origAbort) {
|
|
origAbort.signal.addEventListener('abort', () => {
|
|
if (ws.readyState <= WebSocket.OPEN) ws.close();
|
|
}, { once: true });
|
|
}
|
|
|
|
ws.onopen = () => {
|
|
if (firstMessage) {
|
|
ws.send(JSON.stringify(firstMessage));
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === 'frame') {
|
|
updateOverlayPreview(msg.thumbnail, msg);
|
|
} else if (msg.type === 'result') {
|
|
gotResult = true;
|
|
hideOverlaySpinner();
|
|
openLightbox(msg.full_image, buildTestStatsHtml(msg));
|
|
ws.close();
|
|
} else if (msg.type === 'error') {
|
|
hideOverlaySpinner();
|
|
showToast(msg.detail || 'Test failed', 'error');
|
|
ws.close();
|
|
}
|
|
} catch (e) {
|
|
console.error('Error parsing test WS message:', e);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
if (!gotResult) {
|
|
hideOverlaySpinner();
|
|
showToast(t('streams.test.error.failed'), 'error');
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (!gotResult) {
|
|
hideOverlaySpinner();
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function saveTemplate() {
|
|
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
|
|
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
|
|
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
|
|
|
|
if (!name || !engineType) {
|
|
showToast(t('templates.error.required'), 'error');
|
|
return;
|
|
}
|
|
|
|
const description = (document.getElementById('template-description') as HTMLInputElement).value.trim();
|
|
const engineConfig = collectEngineConfig();
|
|
|
|
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
|
|
|
|
try {
|
|
let response;
|
|
if (templateId) {
|
|
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
|
|
} else {
|
|
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.detail || error.message || 'Failed to save template');
|
|
}
|
|
|
|
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
|
|
templateModal.forceClose();
|
|
captureTemplatesCache.invalidate();
|
|
await loadCaptureTemplates();
|
|
} catch (error) {
|
|
console.error('Error saving template:', error);
|
|
document.getElementById('template-error')!.textContent = (error as any).message;
|
|
document.getElementById('template-error')!.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
export async function deleteTemplate(templateId: any) {
|
|
const confirmed = await showConfirm(t('templates.delete.confirm'));
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.detail || error.message || 'Failed to delete template');
|
|
}
|
|
showToast(t('templates.deleted'), 'success');
|
|
captureTemplatesCache.invalidate();
|
|
await loadCaptureTemplates();
|
|
} catch (error) {
|
|
console.error('Error deleting template:', error);
|
|
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
|
|
}
|
|
}
|