1.8.0: red de seguridad tras la auditoría

- Deshacer vuelve a la interfaz (Historial, Últimos cambios y el aviso al aplicar).
- «Elements» ya no marca como stock lo que tenga dentro: 50,7 GB de material
  propio dejaban de respaldarse y salían como desechables.
- El plan contrasta el mes de la carpeta con la fecha de los archivos y avisa
  cuando no cuadran (727 GB en el material real iban al mes equivocado en verde).
- aplicar(): un archivo bloqueado ya no corta el resto de su grupo.
- Un escaneo interrumpido no reemplaza el inventario bueno.
- Las uniones de carpeta de Windows no se recorren (colgaban el escaneo).
- Abrir una carpeta pasó de 8,83 s a 0,21 s.
- Plantillas: _Papelera migra a _Para borrar.
- .dat/.idx/.bak dejan de ser basura; Cache deja de ser proxies; abreviaturas
  de mes bajan a confianza media.
- Convertidor: varios formatos por clip, originales apartados fuera de
  candidatos, sufijo en modo espejo, temporal borrado al fallar, una sola cola.
- Interfaz: accion() no pisa el estado del botón; rutas de Windows bien recortadas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 10:43:42 -05:00
padre 6bdf6a8457
commit b977fe1b3e
Se han modificado 5 ficheros con 315 adiciones y 80 borrados

Ver fichero

@@ -437,7 +437,7 @@
<!-- ============ HISTORIAL ============ -->
<section id="t-historial">
<div class="cab"><h2 class="v">Historial</h2><div class="der"><label class="mut" style="font-size:12.5px"><input type="checkbox" id="bitSoloRaiz" checked> solo esta carpeta</label></div></div>
<p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, con ejemplos de qué fue a dónde. <span class="q">?</span><span class="mas">La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p>
<p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, y <b>Deshacer</b> devuelve cada archivo a donde estaba. <span class="q">?</span><span class="mas">Si alguien movió un archivo después, ese se salta y se avisa. La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p>
<div id="papeleraLinea" class="hint" style="display:none"></div>
<div id="historial"></div>
</section>
@@ -503,10 +503,14 @@ function aviso(texto, tipo, enlace, detalle){
}
async function accion(btn, fn){
if (btn.disabled) return;
const antes = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="spin"></span>' + antes;
// se marca lo que pusimos nosotros: si la acción deja OTRA etiqueta (porque recalculó el plan, vació la
// lista o volvió a bloquear el botón), esa manda. Antes el finally la pisaba y quedaban botones
// habilitados con la cuenta vieja: «Aplicar todo lo marcado (23)» sobre un plan que ya no tenía 23.
const antes = btn.innerHTML, marca = '<span class="spin"></span>' + antes;
btn.disabled = true; btn.innerHTML = marca;
try { await fn(); }
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); }
finally { btn.disabled = false; btn.innerHTML = antes; }
finally { if (btn.innerHTML === marca) { btn.disabled = false; btn.innerHTML = antes; } }
}
// qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí
const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || ''));
@@ -578,9 +582,10 @@ function huella(E){
let SONDEO = null;
async function sondear(){ if (SONDEO) return; SONDEO = cargarEstado().catch(e => console.warn(e)).finally(() => { SONDEO = null; }); }
setInterval(sondear, 2500);
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; VOC = []; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
function limpiarTablas(){
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; estadoPlan();
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; PLAN_ABIERTOS = new Set();
$('#planIntro').innerHTML = 'Pulsa <b>Calcular</b> para ver, paso a paso, qué haría la app en esta carpeta.'; estadoPlan();
$('#optCards').innerHTML = ''; $('#anaEstado').textContent = ''; $('#convLista').innerHTML = '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>'; bloquear('btnEncolar', 'x'); $('#btnEncolar').textContent = 'Convertir';
$('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero');
$('#respCards').innerHTML = ''; $('#respTipos').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respRawLista').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respMuestra').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respEstado').textContent = ''; bloquear('btnRespCopiar', 'Calcula primero');
@@ -594,7 +599,7 @@ function tareasDe(E){
if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); }
if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); }
});
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)});
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
@@ -690,7 +695,8 @@ async function cargarCarpeta(){
$('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : '');
}
const lotes = CARPETA.lotes || [];
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span></div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && l.lote.startsWith('optimizar') ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
}
function queSeHizo(lote){
if (!lote) return 'Cambio';
@@ -886,7 +892,8 @@ function fraseDe(r){
function pintarPlan(){
if (!PLAN) { estadoPlan(); return; }
const f = ($('#filtroPlan').value || '').toLowerCase();
$('#planAvisos').innerHTML = (PLAN.avisos || []).map(a => `<div class="hint warn">${esc(a)}</div>`).join('');
const avs = PLAN.avisos || [];
$('#planAvisos').innerHTML = avs.length ? `<div class="hint warn"><b>${avs.length === 1 ? 'Revisa esto antes de aplicar' : 'Revisa estas ' + avs.length + ' cosas antes de aplicar'}:</b><ul style="margin:6px 0 0;padding-left:18px">${avs.map(a => `<li>${esc(a)}</li>`).join('')}</ul></div>` : '';
const porPaso = {}; PASOS_PLAN.forEach(p => porPaso[p.k] = []);
PLAN.filas.forEach(r => (porPaso[r.paso || 'resto'] = porPaso[r.paso || 'resto'] || []).push(r));
const tot = PLAN.filas.length, totB = PLAN.filas.reduce((a, r) => a + r.bytes, 0), dud = PLAN.filas.filter(r => r.confianza === 'baja').length;
@@ -912,11 +919,11 @@ function pintarPlan(){
$('#filtroPlan').oninput = pintarPlan;
function aplicarFilas(m, btn, titulo){
if (!m.length) return;
modal(titulo, `<p>Se mueven <b>${m.length}</b> cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra.</b> Es un cambio de sitio dentro del mismo disco. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no bloqueen archivos.</p>`, () => accion(btn, async () => {
modal(titulo, `<p>Se mueven <b>${m.length}</b> cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra</b> y se puede deshacer desde el Historial. Es un cambio de sitio dentro del mismo disco. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no bloqueen archivos.</p>`, () => accion(btn, async () => {
const destinos = {}; m.forEach(r => { if (r.editado) destinos[r.id] = r.destino_rel; });
const res = await api('/api/aplicar', {raiz: PLAN.raiz, ids: m.map(r => r.id), destinos});
PLAN = null; MAPA = null;
aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Ver historial', ir: () => irA('historial')});
aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(res.lote)});
if (res.errores.length) modal('Algunos no se pudieron mover', `<pre>${esc(res.errores.join('\n'))}</pre>`, null);
await cargarEstado(); await calcularPlan();
}));
@@ -1078,7 +1085,8 @@ async function cargarBit(){
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span><span></span></div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
}
$('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad'));
@@ -1132,7 +1140,7 @@ document.addEventListener('drop', seguro(async e => {
if (window.pywebview || (window.chrome && window.chrome.webview)) {
modal('Leyendo la carpeta…', `<p><span class="spin"></span> «${esc(nombre)}»</p>`, null);
const s = await api('/api/soltado?nombre=' + encodeURIComponent(nombre));
if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\/][^\/]*$/, '') : s.ruta);
if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\\/][^\\/]*$/, '') : s.ruta);
}
modal('Buscando la carpeta…', `<p><span class="spin"></span> El navegador solo entrega el nombre («${esc(nombre)}»). Buscando en los discos…</p>`, null);
const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre));