const state = { data: null, rules: [], }; const el = (sel) => document.querySelector(sel); function setStatus(msg, type = "info") { const s = el('#status'); s.textContent = msg || ''; s.className = `status ${type}`; } async function fetchJSON(url, options = {}) { const res = await fetch(url, options); const data = await res.json(); if (!res.ok || data.status === 'error') { const message = data && data.message ? data.message : `HTTP ${res.status}`; throw new Error(message); } return data; } function ruleRow(rule, index) { const div = document.createElement('div'); div.className = 'rule-row'; div.innerHTML = `
`; // bindings const cEl = div.querySelector('.comment'); const pEl = div.querySelector('.pattern'); cEl.addEventListener('input', (e) => { state.rules[index].__comment = e.target.value; autoResize(e.target); }); pEl.addEventListener('input', (e) => { state.rules[index].pattern = e.target.value; autoResize(e.target); }); // Nota: no auto-redimensionar en el render inicial para mantener 2 líneas visibles div.querySelector('.replacement').addEventListener('input', (e) => state.rules[index].replacement = e.target.value); div.querySelector('.action').addEventListener('change', (e) => state.rules[index].action = e.target.value); div.querySelector('.type').addEventListener('change', (e) => state.rules[index].type = e.target.value); div.querySelector('.priority').addEventListener('input', (e) => state.rules[index].priority = parseInt(e.target.value, 10)); div.querySelector('.up').addEventListener('click', () => moveRule(index, -1)); div.querySelector('.down').addEventListener('click', () => moveRule(index, 1)); div.querySelector('.del').addEventListener('click', () => removeRule(index)); div.querySelector('.dup').addEventListener('click', () => duplicateRule(index)); div.querySelector('.test').addEventListener('click', () => openModal(index)); return div; } function render() { const list = el('#rulesList'); list.innerHTML = ''; state.rules .map((r, i) => [r, i]) .sort((a, b) => (a[0].priority ?? 9999) - (b[0].priority ?? 9999) || a[1] - b[1]) .forEach(([r, i]) => list.appendChild(ruleRow(r, i))); const raw = { ...state.data, rules: state.rules, }; el('#rawJson').value = JSON.stringify(raw, null, 2); } function moveRule(index, delta) { const newIndex = index + delta; if (newIndex < 0 || newIndex >= state.rules.length) return; const tmp = state.rules[index]; state.rules[index] = state.rules[newIndex]; state.rules[newIndex] = tmp; render(); } function removeRule(index) { state.rules.splice(index, 1); render(); } function duplicateRule(index) { const original = state.rules[index]; const copy = JSON.parse(JSON.stringify(original)); state.rules.splice(index + 1, 0, copy); render(); } function addRule() { state.rules.push({ __comment: '', pattern: '', replacement: '', action: 'replace', type: 'string', priority: 5, }); render(); } async function loadAll() { try { setStatus('Cargando...', 'info'); const [meta, rulesRes] = await Promise.all([ fetchJSON('/api/meta'), fetchJSON('/api/rules'), ]); state.data = rulesRes.data; state.rules = Array.isArray(rulesRes.data.rules) ? JSON.parse(JSON.stringify(rulesRes.data.rules)) : []; el('#metaInfo').textContent = meta.exists ? `${meta.path} (${meta.size} bytes) — modificado: ${meta.modified}` : 'Archivo no encontrado'; render(); setStatus('Listo', 'success'); } catch (e) { setStatus(`Error: ${e.message}`, 'error'); } } async function saveAll() { try { setStatus('Guardando...', 'info'); // Fusionar: tomar el JSON avanzado si es válido, pero SIEMPRE usar las reglas del estado actual const payloadFromState = { ...state.data, rules: state.rules }; let payload = payloadFromState; try { const parsed = JSON.parse(el('#rawJson').value); payload = { ...parsed, rules: state.rules }; } catch (_) { /* si el JSON está inválido, persistimos el estado visible */ } const res = await fetchJSON('/api/rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); setStatus(`${res.message} (backup: ${res.backup})`, 'success'); await loadAll(); } catch (e) { setStatus(`Error: ${e.message}`, 'error'); } } function wireUI() { const bind = (selector, event, handler) => { const node = el(selector); if (node) node.addEventListener(event, handler); return node; }; bind('#btnReload', 'click', loadAll); bind('#btnSave', 'click', saveAll); bind('#btnAdd', 'click', addRule); bind('#btnShutdown', 'click', async () => { try { await fetchJSON('/_shutdown', { method: 'POST' }); } catch (_) { } try { window.close(); } catch (_) { } }); // modal bind('#modalClose', 'click', closeModal); bind('#btnModalCancel', 'click', closeModal); bind('#btnModalApply', 'click', applyModalToRule); bind('#btnModalTest', 'click', testModalRule); } document.addEventListener('DOMContentLoaded', async () => { wireUI(); await loadAll(); // Iniciar heartbeat periódico para indicar presencia del cliente try { const beat = async () => { try { await fetch('/api/heartbeat', { cache: 'no-store' }); } catch (_) { /* noop */ } }; // primer latido inmediato y luego cada 10s beat(); setInterval(beat, 10000); } catch (_) { /* noop */ } }); // --- Auto-resize helpers --- function autoResize(t) { t.style.height = 'auto'; t.style.height = (t.scrollHeight) + 'px'; } // --- Modal logic --- let modalIndex = -1; function openModal(index) { modalIndex = index; const r = state.rules[index]; // fill selects el('#mAction').innerHTML = ['replace', 'remove_line', 'remove_block', 'add_before', 'add_after'] .map(a => ``).join(''); el('#mType').innerHTML = ['string', 'regex', 'left', 'right', 'substring'] .map(t => ``).join(''); el('#mPattern').value = r.pattern || ''; el('#mReplacement').value = r.replacement || ''; el('#mOriginal').textContent = ''; el('#mProcessed').textContent = ''; updateActionHelp(); el('#mAction').addEventListener('change', updateActionHelp, { once: false }); document.getElementById('modal').classList.remove('hidden'); } function closeModal() { document.getElementById('modal').classList.add('hidden'); modalIndex = -1; } function applyModalToRule() { if (modalIndex < 0) return; const r = state.rules[modalIndex]; r.pattern = el('#mPattern').value; r.replacement = el('#mReplacement').value; r.action = el('#mAction').value; r.type = el('#mType').value; closeModal(); render(); } async function testModalRule() { try { const payload = { pattern: el('#mPattern').value, replacement: el('#mReplacement').value, action: el('#mAction').value, type: el('#mType').value, max_chars: 4000, }; const res = await fetchJSON('/api/test_pattern', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); el('#mOriginal').textContent = res.original_excerpt || ''; el('#mProcessed').textContent = res.processed_excerpt || ''; el('#modalMeta').textContent = `Longitud original: ${res.total_len}`; } catch (e) { el('#modalMeta').textContent = `Error: ${e.message}`; } } function actionHelpText(action) { const map = { replace: 'Reemplaza coincidencias de "pattern" por "replacement". Respeta el "type": string/substring (reemplazo literal), regex (expresión regular), left/right (aplica por línea al inicio/fin).', remove_line: 'Elimina las líneas que coinciden con "pattern" según el "type". Si la línea siguiente queda en blanco, también se elimina.', remove_block: 'Elimina bloques completos que coincidan con el patrón multi-línea. Usa "....." como comodín para cualquier texto entre medio. Ej: "Inicio.....Fin" borrará todo desde la línea con "Inicio" hasta la línea con "Fin".', add_before: 'Inserta el contenido de "replacement" en una nueva línea antes de cada línea que coincida con "pattern" (según el "type").', add_after: 'Inserta el contenido de "replacement" en una nueva línea después de cada línea que coincida con "pattern" (según el "type").', }; return map[action] || ''; } function updateActionHelp() { const sel = el('#mAction'); if (!sel) return; const txt = actionHelpText(sel.value); const node = el('#mActionHelp'); if (node) node.textContent = txt; }