280 lines
10 KiB
JavaScript
280 lines
10 KiB
JavaScript
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 = `
|
|
<div><textarea class="comment autoresize" rows="2" placeholder="Comentario">${rule.__comment || ''}</textarea></div>
|
|
<div><textarea class="pattern autoresize" rows="2" placeholder="Pattern">${rule.pattern || ''}</textarea></div>
|
|
<div><input class="replacement" value="${rule.replacement || ''}" placeholder="Replacement"/></div>
|
|
<div>
|
|
<select class="action">
|
|
${['replace', 'remove_line', 'remove_block', 'add_before', 'add_after'].map(a => `<option value="${a}" ${rule.action === a ? 'selected' : ''}>${a}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<select class="type">
|
|
${['string', 'regex', 'left', 'right', 'substring'].map(t => `<option value="${t}" ${rule.type === t ? 'selected' : ''}>${t}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div><input class="priority" type="number" value="${Number.isInteger(rule.priority) ? rule.priority : 5}"/></div>
|
|
<div class="actions">
|
|
<button class="up">↑</button>
|
|
<button class="down">↓</button>
|
|
<button class="dup">⧉</button>
|
|
<button class="del">✕</button>
|
|
<button class="test">Edicion y Test</button>
|
|
</div>
|
|
`;
|
|
|
|
// 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 => `<option value="${a}" ${r.action === a ? 'selected' : ''}>${a}</option>`).join('');
|
|
el('#mType').innerHTML = ['string', 'regex', 'left', 'right', 'substring']
|
|
.map(t => `<option value="${t}" ${r.type === t ? 'selected' : ''}>${t}</option>`).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;
|
|
}
|
|
|
|
|