WIP: mejoras de fidelidad, editor, lógica y suite e2e (checkpoint tras reinicio)
This commit is contained in:
+212
-48
@@ -116,12 +116,60 @@ window.WAM = window.WAM || {};
|
||||
};
|
||||
}
|
||||
|
||||
/* IDs entregados por generarId() que aún pueden no estar en WAM.estado
|
||||
(p. ej. un clon con respuestas anidadas que se inserta al final): así dos
|
||||
llamadas seguidas nunca devuelven el mismo ID. Se vacía al sustituir el
|
||||
estado entero (cargar / resetear / normalizar de un import). */
|
||||
var entregados = new Set();
|
||||
|
||||
WAM.generarId = function () {
|
||||
var conjunto = new Set();
|
||||
var conjunto = new Set(entregados);
|
||||
recolectarIds(WAM.estado, conjunto);
|
||||
return crearAlocador(conjunto)();
|
||||
var id = crearAlocador(conjunto)();
|
||||
entregados.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
/* Alocador por lotes: devuelve una función tomar() que genera IDs únicos
|
||||
frente a WAM.estado, a los ya entregados y a los que ella misma reparte.
|
||||
`extra` (opcional): objeto/array cuyos id también se reservan. */
|
||||
WAM.crearAlocador = function (extra) {
|
||||
var conjunto = new Set(entregados);
|
||||
recolectarIds(WAM.estado, conjunto);
|
||||
if (extra) recolectarIds(extra, conjunto);
|
||||
var tomar = crearAlocador(conjunto);
|
||||
return function () {
|
||||
var id = tomar();
|
||||
entregados.add(id);
|
||||
return id;
|
||||
};
|
||||
};
|
||||
|
||||
/* ————————————————— URLs seguras ————————————————— */
|
||||
|
||||
/* true si la URL puede ir a un href (tipo "enlace") o a un src ("media").
|
||||
Enlaces: http(s), mailto, tel o sin esquema (relativa / "www…").
|
||||
Media: http(s), blob, data:image|video|audio o sin esquema.
|
||||
Rechaza javascript:, vbscript:, data:text/html, file:, etc. */
|
||||
WAM.urlSegura = function (u, tipo) {
|
||||
var s = String(u == null ? "" : u).replace(/[\u0000- \u007f-\u009f]/g, "");
|
||||
if (!s) return true;
|
||||
var m = /^([a-z][a-z0-9+.\-]*):/i.exec(s);
|
||||
if (!m) return true;
|
||||
var esquema = m[1].toLowerCase();
|
||||
if (esquema === "http" || esquema === "https") return true;
|
||||
if (tipo === "media") {
|
||||
if (esquema === "blob") return true;
|
||||
return esquema === "data" && /^data:(image|video|audio)\//i.test(s);
|
||||
}
|
||||
return esquema === "mailto" || esquema === "tel";
|
||||
};
|
||||
|
||||
function urlLimpia(v, tipo) {
|
||||
var s = cad(v).trim();
|
||||
return WAM.urlSegura(s, tipo) ? s : "";
|
||||
}
|
||||
|
||||
/* ————————————————— mensajes ————————————————— */
|
||||
|
||||
var CAMPOS = {
|
||||
@@ -159,65 +207,109 @@ window.WAM = window.WAM || {};
|
||||
|
||||
/* ————————————————— normalización ————————————————— */
|
||||
|
||||
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
|
||||
Usado por cargar() y por editor.importarJSON (import / upgrade). */
|
||||
WAM.normalizar = function (o) {
|
||||
var src = (o && typeof o === "object" && !Array.isArray(o)) ? o : {};
|
||||
/* Profundidad máxima de respuestas encadenadas (defensa ante JSON hostil). */
|
||||
var MAX_PROFUNDIDAD = 12;
|
||||
var MAX_ITEMS = 10;
|
||||
var CLAVES_PROHIBIDAS = ["__proto__", "constructor", "prototype"];
|
||||
var RE_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
||||
|
||||
/* Índice que la reproducción "pulsa" sola (entero ≥ 0) o null. */
|
||||
function indiceAuto(v) {
|
||||
if (v === null || v === undefined || v === "" || typeof v === "boolean") return null;
|
||||
var n = typeof v === "number" ? v : Number(String(v).trim());
|
||||
if (!isFinite(n) || n < 0 || Math.floor(n) !== n) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function lista(v, max) {
|
||||
return (Array.isArray(v) ? v : []).slice(0, max || MAX_ITEMS);
|
||||
}
|
||||
|
||||
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
|
||||
Usado por cargar(), resetear() y editor.importarJSON (import / upgrade).
|
||||
- Acepta también un array de mensajes suelto como raíz.
|
||||
- IDs: conserva los válidos y únicos; duplicados o inválidos se reasignan.
|
||||
No reserva los IDs del WAM.estado vivo (el estado se sustituye entero).
|
||||
- URLs peligrosas (javascript:, data:text/html…) se vacían.
|
||||
- Respuestas en forma de cadena → mensaje de texto.
|
||||
- Conserva campos extra primitivos desconocidos (compatibilidad hacia
|
||||
delante con campos que añada el editor). */
|
||||
WAM.normalizar = function (o) {
|
||||
var src = Array.isArray(o) ? { mensajes: o }
|
||||
: ((o && typeof o === "object") ? o : {});
|
||||
|
||||
entregados = new Set();
|
||||
var conjunto = new Set();
|
||||
recolectarIds(src, conjunto);
|
||||
recolectarIds(WAM.estado, conjunto); /* evita colisiones con lo vivo */
|
||||
var tomar = crearAlocador(conjunto);
|
||||
var reclamados = new Set();
|
||||
|
||||
function normalizarMensaje(msj, forzarEntrada) {
|
||||
var m = (msj && typeof msj === "object") ? msj : {};
|
||||
function idDe(v) {
|
||||
if (typeof v === "number" && isFinite(v)) v = String(v);
|
||||
if (typeof v === "string" && RE_ID.test(v) && !reclamados.has(v)) {
|
||||
reclamados.add(v);
|
||||
return v;
|
||||
}
|
||||
var nuevo = tomar();
|
||||
reclamados.add(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
|
||||
function normalizarMensaje(msj, forzarEntrada, profundidad) {
|
||||
var m;
|
||||
if (typeof msj === "string") m = { tipo: "text", texto: msj };
|
||||
else m = (msj && typeof msj === "object" && !Array.isArray(msj)) ? msj : {};
|
||||
var tipo = TIPOS.indexOf(m.tipo) !== -1 ? m.tipo : "text";
|
||||
|
||||
var msg = {
|
||||
id: (typeof m.id === "string" && m.id) ? m.id : tomar(),
|
||||
id: idDe(m.id),
|
||||
dir: (!forzarEntrada && m.dir === "out") ? "out" : "in",
|
||||
tipo: tipo,
|
||||
hora: (typeof m.hora === "string" && m.hora) ? m.hora : WAM.horaAhora(),
|
||||
hora: (typeof m.hora === "string" && m.hora.trim()) ? m.hora.trim().slice(0, 12) : WAM.horaAhora(),
|
||||
estado: (m.estado === "delivered" || m.estado === "read") ? m.estado : "sent"
|
||||
};
|
||||
|
||||
function respuestas(valor) {
|
||||
var lista = Array.isArray(valor) ? valor : [];
|
||||
return lista.map(function (r) { return normalizarMensaje(r, true); });
|
||||
if (profundidad >= MAX_PROFUNDIDAD) return [];
|
||||
return lista(valor, 20).map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
|
||||
}
|
||||
|
||||
function botones(valor) {
|
||||
var lista = Array.isArray(valor) ? valor : [];
|
||||
return lista.map(function (b) {
|
||||
var boton = (b && typeof b === "object") ? b : {};
|
||||
return lista(valor).map(function (b) {
|
||||
var boton = (b && typeof b === "object") ? b : { texto: typeof b === "string" ? b : "" };
|
||||
var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) };
|
||||
/* Botón URL de plantilla (research-api §7.2): se conserva si viene */
|
||||
if (typeof boton.url === "string") out.url = boton.url;
|
||||
if (typeof boton.url === "string") out.url = urlLimpia(boton.url, "enlace");
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function autoPulsar() {
|
||||
var n = indiceAuto(m.autoPulsar);
|
||||
if (n !== null) msg.autoPulsar = n;
|
||||
}
|
||||
|
||||
switch (tipo) {
|
||||
case "text":
|
||||
msg.texto = cad(m.texto);
|
||||
break;
|
||||
case "image":
|
||||
case "video":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.caption = cad(m.caption);
|
||||
break;
|
||||
case "audio":
|
||||
msg.src = cad(m.src);
|
||||
msg.voz = m.voz === true;
|
||||
msg.duracion = cad(m.duracion, "0:00");
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.voz = m.voz === true || m.voz === "true";
|
||||
msg.duracion = cad(m.duracion, "0:00") || "0:00";
|
||||
break;
|
||||
case "document":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.caption = cad(m.caption);
|
||||
msg.filename = cad(m.filename, "documento.pdf");
|
||||
msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf";
|
||||
break;
|
||||
case "sticker":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
break;
|
||||
case "location":
|
||||
msg.latitud = cad(m.latitud);
|
||||
@@ -232,32 +324,26 @@ window.WAM = window.WAM || {};
|
||||
msg.empresa = cad(m.empresa);
|
||||
break;
|
||||
case "template":
|
||||
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
|
||||
msg.headerTexto = cad(m.headerTexto);
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.botones = botones(m.botones);
|
||||
break;
|
||||
case "buttons":
|
||||
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
|
||||
msg.headerTexto = cad(m.headerTexto);
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.headerMedia = urlLimpia(m.headerMedia, "media");
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.botones = botones(m.botones);
|
||||
autoPulsar();
|
||||
break;
|
||||
case "list":
|
||||
msg.header = cad(m.header);
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.boton = cad(m.boton);
|
||||
msg.secciones = (Array.isArray(m.secciones) ? m.secciones : []).map(function (s) {
|
||||
msg.secciones = lista(m.secciones).map(function (s) {
|
||||
var seccion = (s && typeof s === "object") ? s : {};
|
||||
return {
|
||||
titulo: cad(seccion.titulo),
|
||||
filas: (Array.isArray(seccion.filas) ? seccion.filas : []).map(function (f) {
|
||||
var fila = (f && typeof f === "object") ? f : {};
|
||||
filas: lista(seccion.filas).map(function (f) {
|
||||
var fila = (f && typeof f === "object") ? f : { titulo: typeof f === "string" ? f : "" };
|
||||
return {
|
||||
titulo: cad(fila.titulo),
|
||||
descripcion: cad(fila.descripcion),
|
||||
@@ -266,28 +352,39 @@ window.WAM = window.WAM || {};
|
||||
})
|
||||
};
|
||||
});
|
||||
autoPulsar();
|
||||
break;
|
||||
case "cta_url":
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.headerMedia = urlLimpia(m.headerMedia, "media");
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.textoBoton = cad(m.textoBoton);
|
||||
msg.url = cad(m.url);
|
||||
msg.url = urlLimpia(m.url, "enlace");
|
||||
break;
|
||||
case "carousel":
|
||||
msg.body = cad(m.body);
|
||||
msg.cards = (Array.isArray(m.cards) ? m.cards : []).map(function (c) {
|
||||
msg.cards = lista(m.cards).map(function (c) {
|
||||
var card = (c && typeof c === "object") ? c : {};
|
||||
return {
|
||||
media: cad(card.media),
|
||||
media: urlLimpia(card.media, "media"),
|
||||
texto: cad(card.texto),
|
||||
textoBoton: cad(card.textoBoton),
|
||||
url: cad(card.url),
|
||||
url: urlLimpia(card.url, "enlace"),
|
||||
respuestas: respuestas(card.respuestas)
|
||||
};
|
||||
});
|
||||
autoPulsar();
|
||||
break;
|
||||
}
|
||||
|
||||
/* Campos extra primitivos (p. ej. headerFilename) se conservan. */
|
||||
for (var k in m) {
|
||||
if (!Object.prototype.hasOwnProperty.call(m, k)) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(msg, k) || k === "autoPulsar") continue;
|
||||
if (CLAVES_PROHIBIDAS.indexOf(k) !== -1 || k === "cita") continue;
|
||||
var v = m[k];
|
||||
if (typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && isFinite(v))) msg[k] = v;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -297,18 +394,48 @@ window.WAM = window.WAM || {};
|
||||
tema: src.tema === "oscuro" ? "oscuro" : "claro",
|
||||
cuenta: {
|
||||
nombre: cad(cuentaSrc.nombre, "Café Aurora"),
|
||||
avatar: cad(cuentaSrc.avatar),
|
||||
avatar: urlLimpia(cuentaSrc.avatar, "media"),
|
||||
subtitulo: cad(cuentaSrc.subtitulo, "en línea"),
|
||||
verificado: cuentaSrc.verificado === true
|
||||
verificado: cuentaSrc.verificado === true || cuentaSrc.verificado === "true",
|
||||
/* B: fecha "Hoy" + aviso de cifrado al inicio del chat (por defecto sí) */
|
||||
avisoCifrado: !(cuentaSrc.avisoCifrado === false || cuentaSrc.avisoCifrado === "false")
|
||||
},
|
||||
mensajes: (Array.isArray(src.mensajes) ? src.mensajes : []).map(function (x) {
|
||||
return normalizarMensaje(x, false);
|
||||
mensajes: lista(src.mensajes, 500).map(function (x) {
|
||||
return normalizarMensaje(x, false, 0);
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
/* ————————————————— persistencia ————————————————— */
|
||||
|
||||
/* Aviso de error de guardado: se emite UNA vez hasta el siguiente guardado
|
||||
correcto, por el hook opcional WAM.onErrorGuardar(texto, error). */
|
||||
var avisoEmitido = false;
|
||||
var ultimoOk = true;
|
||||
|
||||
function esCuota(e) {
|
||||
return !!e && (e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
e.code === 22 || e.code === 1014);
|
||||
}
|
||||
|
||||
function escribir() {
|
||||
try {
|
||||
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
|
||||
avisoEmitido = false;
|
||||
ultimoOk = true;
|
||||
} catch (e) {
|
||||
ultimoOk = false;
|
||||
if (avisoEmitido) return;
|
||||
avisoEmitido = true;
|
||||
var texto = esCuota(e)
|
||||
? "No se pudo guardar: el almacenamiento del navegador está lleno (imágenes muy pesadas). Exporta la demo a JSON para no perder los cambios."
|
||||
: "No se pudo guardar en este navegador (almacenamiento local bloqueado). Exporta la demo a JSON para no perder los cambios.";
|
||||
if (typeof WAM.onErrorGuardar === "function") {
|
||||
try { WAM.onErrorGuardar(texto, e); } catch (e2) { /* hook opcional */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WAM.cargar = function () {
|
||||
var bruto = null;
|
||||
try { bruto = window.localStorage.getItem(WAM.CLAVE); } catch (e) { bruto = null; }
|
||||
@@ -316,20 +443,57 @@ window.WAM = window.WAM || {};
|
||||
if (bruto) {
|
||||
try { dato = JSON.parse(bruto); } catch (e) { dato = null; }
|
||||
}
|
||||
if (!dato) dato = WAM.datosDemo();
|
||||
if (!dato || typeof dato !== "object") dato = WAM.datosDemo();
|
||||
WAM.estado = null;
|
||||
WAM.estado = WAM.normalizar(dato);
|
||||
return WAM.estado;
|
||||
};
|
||||
|
||||
/* Guardado con coalescencia: la primera llamada escribe al instante; las
|
||||
que llegan en los 250 ms siguientes (p. ej. cada tecla del editor) se
|
||||
agrupan en una sola escritura final. pagehide/visibilitychange vacían
|
||||
lo pendiente para no perder nada al recargar o cerrar. */
|
||||
var VENTANA = 250;
|
||||
var temporizador = null;
|
||||
var pendiente = false;
|
||||
|
||||
WAM.guardar = function () {
|
||||
try {
|
||||
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
|
||||
} catch (e) { /* sin localStorage disponible (cuota/file://): se ignora */ }
|
||||
if (temporizador) { pendiente = true; return; }
|
||||
escribir();
|
||||
temporizador = setTimeout(function fin() {
|
||||
temporizador = null;
|
||||
if (pendiente) {
|
||||
pendiente = false;
|
||||
escribir();
|
||||
temporizador = setTimeout(fin, VENTANA);
|
||||
}
|
||||
}, VENTANA);
|
||||
};
|
||||
|
||||
/* Escritura inmediata de lo pendiente (true si quedó guardado). */
|
||||
WAM.guardarYa = function () {
|
||||
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
|
||||
pendiente = false;
|
||||
escribir();
|
||||
return ultimoOk;
|
||||
};
|
||||
|
||||
function vaciarPendiente() { if (pendiente) WAM.guardarYa(); }
|
||||
if (typeof window !== "undefined" && window.addEventListener) {
|
||||
window.addEventListener("pagehide", vaciarPendiente);
|
||||
window.addEventListener("beforeunload", vaciarPendiente);
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "hidden") vaciarPendiente();
|
||||
});
|
||||
}
|
||||
|
||||
WAM.resetear = function () {
|
||||
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
|
||||
pendiente = false;
|
||||
try { window.localStorage.removeItem(WAM.CLAVE); } catch (e) { /* idem */ }
|
||||
WAM.estado = null; /* los IDs de la demo vuelven a empezar en m1 */
|
||||
WAM.estado = WAM.normalizar(WAM.datosDemo());
|
||||
avisoEmitido = false;
|
||||
};
|
||||
|
||||
/* ————————————————— demo por defecto: "Café Aurora" ————————————————— */
|
||||
|
||||
Reference in New Issue
Block a user