3.5.0: «Qué va a pasar» se explica con una carpeta, y una barra dice por dónde va

Munka, con el plan de Diana Méndez delante: «se lee mucho pero se entiende poco», «lo de ordenar
por año y por mes, ¿me toca seleccionar uno a uno o lo hace todo?», y quería una barra para saber
por dónde va y si hace falta algo.

- Una frase arriba y un solo botón, «Aplicar los N cambios»: un clic lo hace todo, en orden.
- Tres fichas, una por paso, con cambios, peso y cuántos quedan sin marcar; se pulsa una y se ve.
- Un ejemplo animado: la fila más representativa del paso como dos ramas de carpetas, «Ahora» y
  «Después», y lo que se mueve vuela de una a otra; debajo, la frase y el porqué. «Otro ejemplo»
  recorre las filas.
- La lista, corta: de dónde → a dónde y la cuenta; el motivo solo con «ver por qué». Lo que no va
  solo queda aparte y contado (cosméticos «01 - Celular» → «01- Celular», y dudosos), con «marcar
  los cosméticos».
- Al aplicar, el servidor cuenta archivo a archivo (/api/aplicando) y la interfaz lo sondea: barra,
  qué se mueve ahora, y al acabar qué se hizo, qué falló y qué queda sin hacer.
- Los avisos van plegados bajo «N cosas que conviene mirar antes de aplicar».

Pruebas: probar 19, prueba_orden 33, prueba_funcional 30 (comprueba que el avance acaba en tantas
de tantas), prueba_ventana 18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-10 10:59:16 -05:00
padre 50da026373
commit 22bf2a8657
Se han modificado 4 ficheros con 303 adiciones y 81 borrados

Ver fichero

@@ -255,24 +255,81 @@
.ac.hecho .cifra{font-weight:500;color:var(--mut)}
.ac button{white-space:nowrap}
@media (max-width:820px){ .ac{grid-template-columns:26px minmax(0,1fr)} .ac .der{grid-column:2;align-items:flex-start} }
.paso{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:10px}
.paso .pc{display:flex;gap:12px;align-items:center;padding:12px 14px}
.paso .num{width:26px;height:26px;border-radius:50%;background:var(--acc2);color:var(--acc);font-weight:700;display:flex;align-items:center;justify-content:center;flex:none;font-size:13px}
.paso.vacio .num{background:var(--ok2);color:var(--ok)}
.paso .pc .tx{flex:1;min-width:0}
.paso .pc .tx b{font-size:14px}
.paso .pc .tx .mut{font-size:12.5px;margin-top:2px}
.paso .pc .der{display:flex;gap:8px;align-items:center;flex:none}
.paso .res{font-size:12.5px;color:var(--mut);white-space:nowrap}
.paso .detalle{border-top:1px solid var(--line2);padding:6px 14px 10px;display:none}
.paso.abierto .detalle{display:block}
.paso .fila{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:10px;align-items:start;padding:7px 0;border-bottom:1px solid var(--line2);font-size:13px}
.paso .fila:last-child{border-bottom:0}
.paso .fila .de{color:var(--mut);font-size:12px;margin-top:2px}
.paso .fila.baja .frase{color:var(--mut)}
.paso .fila .ed{border:0;background:none;color:var(--acc);cursor:pointer;font-size:12px;padding:0 4px}
.paso .grp{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);padding:10px 0 4px;font-weight:700}
#planIntro b.grande{font-size:14px}
/* plan: fichas de los tres pasos, el ejemplo animado, la lista y la barra de avance */
.fichas{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0 0 14px}
@media(max-width:900px){.fichas{grid-template-columns:1fr}}
.ficha{display:grid;grid-template-columns:26px minmax(0,1fr);gap:10px;align-items:start;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px 12px;cursor:pointer;position:relative}
.ficha:hover{border-color:var(--acc)}
.ficha.on{border-color:var(--acc);box-shadow:inset 0 0 0 1px var(--acc);background:var(--acc2)}
.ficha.vacio{opacity:.7;cursor:default}
.ficha .num{width:26px;height:26px;border-radius:50%;background:var(--acc2);color:var(--acc);font-weight:700;display:flex;align-items:center;justify-content:center;font-size:13px}
.ficha.on .num{background:var(--acc);color:#fff}
.ficha.vacio .num{background:var(--ok2);color:var(--ok)}
.ficha .tx{min-width:0}
.ficha .tx b{font-size:14px;display:block}
.ficha .tx .q{font-size:12px;color:var(--mut);margin-top:1px}
.ficha .cifra{font-size:12.5px;margin-top:6px}
.ficha .cifra b{font-size:15px}
.ficha .lnk{position:absolute;right:6px;bottom:6px;font-size:12px;padding:3px 6px}
.ejemplo{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;margin-bottom:14px;overflow:hidden}
.ejcab{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.ejcols{display:grid;grid-template-columns:minmax(0,1fr) 40px minmax(0,1fr);gap:8px;align-items:start}
@media(max-width:820px){.ejcols{grid-template-columns:1fr}.ejflecha{display:none}}
.ejtit{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);font-weight:700;margin-bottom:6px}
.ejarbol{background:var(--panel2);border-radius:8px;padding:10px 12px;min-height:96px;font-size:12.5px}
.ejarbol .nivel{display:flex;align-items:center;gap:6px;padding:3px 0 3px calc(var(--n,0) * 18px);position:relative;min-width:0}
.ejarbol .nivel>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ejarbol .nivel:not(.raiz)::before{content:'';position:absolute;left:calc(var(--n,0) * 18px - 11px);top:-5px;width:8px;height:16px;border-left:1px solid #c7cbd1;border-bottom:1px solid #c7cbd1;border-radius:0 0 0 4px}
.ejarbol .raiz{font-weight:700}
.fo{display:inline-block;width:14px;height:11px;flex:none;background:#e9b949;border-radius:2px;position:relative;margin-top:2px}
.fo::before{content:'';position:absolute;left:0;top:-3px;width:6px;height:4px;background:#e9b949;border-radius:2px 2px 0 0}
.ejarbol .mv{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:2px 8px;border-radius:6px;background:#fff;border:1px solid var(--acc);color:var(--tx);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.08)}
.ejarbol .mv.chip{background:var(--acc);color:#fff}
.ejarbol .mv.fantasma{opacity:.3;border-style:dashed;box-shadow:none}
.ejarbol .mv.llega{animation:llega .5s ease-out}
@keyframes llega{0%{transform:scale(1.15);box-shadow:0 0 0 6px var(--acc2)}100%{transform:scale(1);box-shadow:0 1px 3px rgba(0,0,0,.08)}}
.vuelo{z-index:3;pointer-events:none;will-change:transform}
.ejflecha{text-align:center;color:var(--acc);font-size:22px;padding-top:44px}
.ejtexto{margin-top:10px;font-size:13px;opacity:0;transform:translateY(4px);transition:opacity .4s,transform .4s}
.ejtexto.visto{opacity:1;transform:none}
.ejtexto .pq{color:var(--mut);font-size:12.5px;margin-top:2px}
.ejpie{font-size:12.5px;margin-top:8px}
.lista{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 14px 10px;margin-bottom:14px}
.lista .lcab{display:flex;gap:10px;align-items:center;padding:8px 0;font-size:13px;flex-wrap:wrap}
.lista .grp{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);padding:10px 0 4px;font-weight:700}
.lista .fila{display:grid;grid-template-columns:22px minmax(0,1.1fr) 18px minmax(0,1.3fr) auto auto;gap:2px 10px;align-items:center;padding:6px 0;border-bottom:1px solid var(--line2);font-size:13px}
.lista .fila:last-child{border-bottom:0}
.lista .fila .de{min-width:0}
.lista .fila .de b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lista .fila .en{display:block;font-size:11.5px;color:var(--mut);font-family:ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lista .fila .fl{color:var(--acc);text-align:center}
.lista .fila .a{font-family:ui-monospace,Consolas,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.lista .fila .a b{font-family:Manrope,system-ui,sans-serif;font-size:13px}
.lista .fila .meta{font-size:12px;color:var(--mut);white-space:nowrap;font-variant-numeric:tabular-nums}
.lista .fila .acts{white-space:nowrap;display:flex;gap:4px;align-items:center;justify-content:flex-end;min-width:150px}
.lista .fila .ed{border:0;background:none;color:var(--acc);cursor:pointer;font-size:12px;padding:0 4px;opacity:0}
.lista .fila:hover .ed,.lista .fila:focus-within .ed{opacity:1}
.lista .fila .pq{grid-column:2/7;display:none;font-size:12px;color:var(--mut)}
.lista.porque .fila .pq{display:block}
.lista .fila.sinmarcar{opacity:.65}
.lista .fila.esej .de b::after{content:'ejemplo';font-size:10px;color:var(--acc);font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin-left:6px;border:1px solid var(--acc);border-radius:99px;padding:0 6px}
.lista .opc{margin-top:10px;border-top:1px dashed var(--line);padding-top:4px}
@media(max-width:900px){.lista .fila{grid-template-columns:22px minmax(0,1fr) auto}.lista .fila .fl{display:none}.lista .fila .a,.lista .fila .meta,.lista .fila .acts{grid-column:2}.lista .fila .acts{justify-content:flex-start}.lista .fila .pq{grid-column:2/4}}
details.avisos summary{cursor:pointer;list-style:none}
details.avisos summary::before{content:'▸';display:inline-block;width:14px;color:var(--warn);transition:transform .15s}
details.avisos[open] summary::before{transform:rotate(90deg)}
details.avisos ul{margin:6px 0 0;padding-left:22px}
.avance{background:var(--panel);border:1px solid var(--acc);border-radius:8px;padding:12px 14px;margin-bottom:14px}
.avance.listo{border-color:var(--ok)}
.avance.listo.conerr{border-color:var(--warn)}
.avance .l1{display:flex;gap:8px;align-items:center;font-size:13.5px;flex-wrap:wrap}
.avance .ico{width:22px;height:22px;border-radius:50%;background:var(--ok2);color:var(--ok);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:13px}
.avance.conerr .ico{background:var(--warn2);color:var(--warn)}
.avance .bar.grande{height:10px;margin:8px 0 6px}
.avance .bar i{transition:width .35s}
.avance.listo .bar i{background:var(--ok)}
.avance .l3{font-size:12.5px}
.nombrar li{grid-template-columns:minmax(0,1fr) auto 110px}
.nombrar li .r{display:block}
.nombrar li .sg{display:block;font-size:12px;color:var(--acc);margin-top:3px;white-space:normal}
@@ -428,16 +485,20 @@
</div>
<div class="sub" id="s-plan">
<div id="planIntro" class="hint" style="margin-bottom:12px">Pulsa <b>Calcular</b> para ver, paso a paso, qué haría la app en esta carpeta.</div>
<div id="planIntro" class="hint" style="margin-bottom:12px">Pulsa <b>Calcular</b> para ver qué haría la app en esta carpeta: lo explica con un ejemplo y lista lo demás. No mueve nada hasta que apliques.</div>
<div class="toolbar" style="margin-bottom:12px">
<button id="btnAplicarSeguros" class="ok" disabled style="order:-1">Aplicar</button>
<span class="mut" id="planUnClic" style="display:none;font-size:12.5px;order:-1">Un clic lo hace todo, en orden. No hay que ir uno por uno.</span>
<button id="btnPlan" class="p">Calcular qué va a pasar</button>
<button id="btnAplicarSeguros" class="ok" disabled>Aplicar todo lo seguro</button>
<span class="sep"></span>
<button class="lnk" id="btnArbol" disabled>Cómo quedaría</button>
<input type="text" id="filtroPlan" placeholder="buscar en el plan…" style="min-width:160px">
</div>
<div id="planAvance"></div>
<div id="planAvisos"></div>
<div id="planPasos"></div>
<div id="planPasos" class="fichas"></div>
<div id="planEjemplo"></div>
<div id="planLista"></div>
</div>
<div class="sub" id="s-nombrar">
@@ -592,7 +653,7 @@
<dl>
<dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd>
<dt>Por nombrar</dt><dd>Las carpetas que no sabe qué son y que no moverá hasta que se lo digas. Propone qué es cada una y tú lo confirmas. No pregunta por las carpetas de la propia app, ni por lo de dentro de las piezas, ni por lo que ya ordena solo. <i>Cómo: mira la forma (si trae sus exportados, su portada y su música es una pieza; si guarda meses dentro, un cajón) y lo que tiene dentro contando archivos, no peso. Lo confirmado se guarda en un diccionario y vale para las carpetas que se llamen exactamente igual, en cualquier disco.</i></dd>
<dt>Qué va a pasar</dt><dd>El plan: a dónde iría cada cosa, sin mover nada. Va en tres pasos que se aplican por separado: apartar basura y proxies, ordenar por año y mes (y por día de rodaje si ese mes hubo más de uno), y ordenar lo demás (proyectos por programa, exportados, música…). <i>Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada fila va marcada como segura, por revisar o dudosa.</i></dd>
<dt>Qué va a pasar</dt><dd>El plan: qué haría la app en la carpeta, sin mover nada todavía. Lo explica con una carpeta de ejemplo, que se ve pasar de donde está a donde va, y debajo lista lo demás. Son tres pasos (apartar basura y proxies, ordenar por año y mes, ordenar lo demás) y un solo clic aplica todo lo marcado, en orden: no hay que ir uno por uno. Lo que no va solo queda aparte y contado: los cambios cosméticos (solo el nombre, «01 - Celular» → «01- Celular») y los dudosos. Mientras aplica, una barra dice por dónde va, archivo a archivo, y al acabar, qué quedó sin hacer. <i>Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada cambio va como seguro, probable o dudoso, y solo los dos primeros se marcan solos.</i></dd>
<dt>Grabaciones</dt><dd>Una carpeta de grabación se reparte por mes, día y cámara, y conserva el nombre de cada sesión («Tomas consultorio»). Un clip ya convertido sigue contando como de su cámara. <i>Cómo: solo se reparte lo que de verdad es una tarjeta volcada; si dentro hay una pieza montada (exportados, portada, proyecto), no se desarma. El año de un clip de Blackmagic, que el nombre no trae, sale del resto de clips de la misma tarjeta y día, y un rodaje que pasa de medianoche cuenta como uno solo.</i></dd>
<dt>Cajones</dt><dd>Una carpeta que guarda meses o años dentro («Orden», «Proyectos\Mayo») no se mueve entera: se abre, y cada cosa va a su mes. Si una pieza ya existe en ese mes, se junta con ella en vez de quedar una carpeta gemela al lado.</dd>
<dt>Repaso de lo ya ordenado</dt><dd>El plan también mira lo que ya está en <span class="path">00_Grabacion</span>: si un clip lleva en el nombre una fecha que no es la del mes donde está, propone llevarlo al suyo. <i>Cómo: solo con fechas escritas por la cámara; la fecha de modificación no cuenta, salvo para lo que se grabó ese mismo día.</i></dd>
@@ -754,7 +815,7 @@ function cfgRaiz(){ return (ESTADO.raices||[]).find(r => r.clave === raizActual(
function irA(t){ $$('nav a').forEach(x => x.classList.toggle('on', x.dataset.t === t)); $$('section').forEach(s => s.classList.toggle('on', s.id === 't-' + t)); pintarIndice(); cargarVista(t); }
function vistaActual(){ return ($$('nav a').find(a => a.classList.contains('on')) || {}).dataset?.t || 'carpeta'; }
$$('nav a').forEach(a => { a.onclick = () => irA(a.dataset.t); a.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); irA(a.dataset.t); } }; });
function subRev(s){ $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); }
function subRev(s){ const venia = $('#s-plan').classList.contains('on'); $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); if (s === 'plan' && !venia && PLAN) setTimeout(animarEjemplo, 60); }
$$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s));
const cargarVista = seguro(async t => {
t = t || vistaActual();
@@ -920,7 +981,7 @@ function tareasDe(E){
});
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)});
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.unidades_total ? E.aplicando.unidades * 100 / E.aplicando.unidades_total : 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', {})}); }
if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) { const t = E.traer; const cop = t.estado === 'copiando'; T.push({raiz: t.raiz, q: cop ? 'Trayendo material' : 'Repartiendo lo traído', d: `${porNombre(t.raiz)} · ${num(t.hechos)} de ${num(t.total)} · ${gb(t.bytes)} de ${gb(t.bytes_total)}${t.actual ? ' · ' + t.actual.slice(-40) : ''}`, pct: cop && t.bytes_total ? Math.round(t.bytes / t.bytes_total * 100) : null, cancelar: cop ? () => api('/api/entrada/cancelar', {}) : null}); }
if (E.automatico && E.automatico.activo) { const a = E.automatico; T.push({raiz: a.raiz, q: 'Orden automático', d: `${a.raiz ? porNombre(a.raiz) + ' · ' : ''}${a.hechas} de ${a.total} carpetas`, pct: a.total ? Math.round(a.hechas * 100 / a.total) : null}); }
@@ -1036,7 +1097,7 @@ async function cargarCarpeta(){
// Una sola lista, en orden, con la cifra de cada cosa y un botón. Sustituye a repartir el trabajo
// entre pestañas dentro de pestañas: el usuario ve de un vistazo qué falta y cuánto pesa.
const IR_ACCION = {
plan: p => { irA('revisar'); subRev('plan'); if (!PLAN) $('#btnPlan').click(); else if (p) { PLAN_ABIERTOS = new Set([p]); pintarPlan(); } },
plan: p => { irA('revisar'); if (PLAN && p) { PLAN_PASO = p; PLAN_EJ = 0; pintarPlan(); } subRev('plan'); if (!PLAN) $('#btnPlan').click(); },
nombrar: () => { irA('revisar'); subRev('nombrar'); },
stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); },
vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); },
@@ -1261,93 +1322,200 @@ function pintarReglas(reglas){
$$('#reglas button').forEach(b => b.onclick = () => accion(b, async () => { const r = reglas[+b.dataset.i]; const d = await api('/api/regla', {cliente: r.cliente, origen: r.origen, destino: null}); pintarReglas(d.reglas); PLAN = null; estadoPlan(); }));
}
// ---------------- revisar: plan (paso a paso, en frases) ----------------
// ---------------- revisar: plan (un ejemplo animado, y la lista de lo demás) ----------------
// La pestaña explica el plan con UNA carpeta (el ejemplo, que se ve pasar de donde está a donde va) y
// debajo lista lo demás que pasa en ese paso. Un clic aplica todo lo marcado; lo que no va solo
// (cosméticos, dudosos) queda aparte, contado y con su porqué, para que nadie tenga que ir uno por uno.
const PASOS_PLAN = [
{k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema (.DS_Store, Thumbs.db, cachés) y proxies se apartan en «_Para borrar». Los proxies se regeneran desde Resolve o Premiere. Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'},
{k: 'fechas', t: 'Ordenar por año y mes', q: 'Los meses toman el nombre «03- Marzo», las grabaciones se reparten por mes y por cámara, y las piezas quedan numeradas dentro de su mes. Lo que ya estaba ordenado en un mes que no es el de su rodaje vuelve al suyo.', vacio: 'Las fechas ya están en orden.'},
{k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música, marca y demás van a su carpeta. Lo dudoso queda sin marcar: la app no lo mueve salvo que lo marques tú.', vacio: 'Nada más que ordenar.'},
{k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema y proxies van a «_Para borrar». Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'},
{k: 'fechas', t: 'Ordenar por año y mes', q: 'Grabaciones y piezas, en la carpeta del mes en que se grabaron.', vacio: 'Las fechas ya están en orden.'},
{k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música y marca, cada cosa a su carpeta.', vacio: 'Nada más que ordenar.'},
];
const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'};
let PLAN_ABIERTOS = new Set(), ACCIONES = [];
let PLAN_PASO = null, PLAN_EJ = 0, PLAN_VER_OPC = false, PLAN_POR_QUE = false, PLAN_AVISOS = false, ACCIONES = [];
const pasoDe = r => r.paso || 'resto';
const filasPaso = k => PLAN ? PLAN.filas.filter(r => pasoDe(r) === k) : [];
const trozos = p => (p || '').split(/[\\/]/).filter(Boolean);
const ultimo = p => trozos(p).pop() || p || '';
const padreDe = p => trozos(p).slice(0, -1).join('\\');
function estadoPlan(){
const hay = !!PLAN;
$('#btnArbol').disabled = !hay;
const seg = hay ? PLAN.filas.filter(r => r.marcado) : [];
bloquear('btnAplicarSeguros', hay ? (seg.length ? '' : 'x') : 'x'); $('#btnAplicarSeguros').textContent = seg.length ? `Aplicar todo lo marcado (${seg.length})` : 'Aplicar todo lo seguro';
bloquear('btnAplicarSeguros', hay ? (seg.length ? '' : 'x') : 'x');
$('#btnAplicarSeguros').textContent = seg.length ? (seg.length === 1 ? 'Aplicar el cambio' : `Aplicar los ${seg.length} cambios`) : hay && PLAN.filas.length ? 'Nada marcado para aplicar' : 'Aplicar';
$('#planUnClic').style.display = seg.length > 1 ? '' : 'none';
$('#btnPlan').textContent = hay ? 'Calcular de nuevo' : 'Calcular qué va a pasar';
$('#btnPlan').classList.toggle('p', !hay);
if (CARPETA) { CARPETA.plan = hay ? {filas: PLAN.filas.length, marcadas: seg.length, baja: PLAN.filas.filter(r => r.confianza === 'baja').length} : null; pintarIndice(); }
}
async function calcularPlan(){
$('#planIntro').innerHTML = '<span class="spin"></span> mirando la carpeta…';
PLAN = await api('/api/plan?raiz=' + encodeURIComponent(raizActual()));
PLAN_EJ = 0; PLAN_VER_OPC = false;
pintarPlan();
}
$('#btnPlan').onclick = () => accion($('#btnPlan'), calcularPlan);
$('#btnPlan').onclick = () => { $('#planAvance').innerHTML = ''; accion($('#btnPlan'), calcularPlan); };
function motivoLlano(m){
return (m || '').replace(/diccionario: palabra «([^»]+)»/g, 'por la palabra «$1» del nombre').replace(/diccionario: contiene «([^»]+)»/g, 'porque el nombre contiene «$1»').replace(/diccionario: «([^»]+)»/g, 'por su nombre («$1»)').replace(/regla aprendida/g, 'lo dijiste tú la vez pasada').replace(/^no reconocida: /, 'la app no reconoce el nombre: ').replace(/\(fecha de modificación\)/g, '(por la fecha de modificación: puede fallar)');
return (m || '').replace(/diccionario: palabra «([^»]+)»/g, 'por la palabra «$1» del nombre').replace(/diccionario: contiene «([^»]+)»/g, 'porque el nombre contiene «$1»').replace(/diccionario: «([^»]+)»/g, 'por su nombre («$1»)').replace(/regla aprendida/g, 'lo dijiste tú la vez pasada').replace(/^no reconocida: /, 'la app no reconoce el nombre: ').replace(/\(fecha de modificación\)/g, '(por la fecha de modificación: puede fallar)').replace(/^cosmético: nombre de pieza a «NN- Título» \(sin marcar por defecto\)$/, 'solo cambia el nombre a la forma «NN- Título»: da igual hacerlo o no');
}
function fraseDe(r){
const nom = x => `«${esc(x.split(/[\\/]/).pop())}»`;
const nom = x => `«${esc(ultimo(x))}»`;
if (r.origen_rel === '(toda la raíz)') return `${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'} repartidos por la carpeta se apartan en «_Para borrar»`;
if (r.accion === 'papelera') return `${nom(r.origen_rel)} se aparta en «_Para borrar»`;
if (r.accion === 'renombrar') return `${nom(r.origen_rel)} pasa a llamarse ${nom(r.destino_rel)}`;
if (r.archivos) return `${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''} de ${nom(r.origen_rel)} van a «${esc(r.destino_rel)}»`;
return `${nom(r.origen_rel)} se mueve a «${esc(r.destino_rel)}»`;
}
// actualiza solo las cifras de cada paso y los botones, sin tocar las filas: así el foco se queda
// donde está y se puede recorrer la lista con el teclado
function actualizarContadores(){
if (!PLAN) return;
PASOS_PLAN.forEach(p => {
const caja = $(`#planPasos .paso[data-k="${p.k}"]`); if (!caja) return;
const rs = PLAN.filas.filter(r => (r.paso || 'resto') === p.k), marc = rs.filter(r => r.marcado);
const res = caja.querySelector('.res');
if (res) res.textContent = `${rs.length} cambio${rs.length > 1 ? 's' : ''} · ${gb(rs.reduce((a, r) => a + r.bytes, 0))}${marc.length < rs.length ? ` · ${marc.length} marcado${marc.length !== 1 ? 's' : ''}` : ''}`;
const bt = caja.querySelector('[data-aplicar]');
if (bt) { bt.disabled = !marc.length; bt.textContent = 'Aplicar' + (marc.length ? ' ' + marc.length : ''); }
});
estadoPlan();
function pasoPorDefecto(){
const con = PASOS_PLAN.find(p => filasPaso(p.k).some(r => r.marcado)) || PASOS_PLAN.find(p => filasPaso(p.k).length);
return con ? con.k : null;
}
// «43 cosméticos y 5 dudosos»: por qué hay cambios sin marcar
function resumenOpc(rs){
const cos = rs.filter(r => r.cosmetico).length, dud = rs.filter(r => !r.cosmetico && r.confianza === 'baja').length, otros = rs.length - cos - dud;
return [cos ? `${cos} cosmético${cos > 1 ? 's' : ''}` : '', dud ? `${dud} dudoso${dud > 1 ? 's' : ''}` : '', otros ? `${otros} que desmarcaste` : ''].filter(Boolean).join(', ').replace(/, ([^,]*)$/, ' y $1');
}
function pintarPlan(){
if (!PLAN) { estadoPlan(); return; }
const f = ($('#filtroPlan').value || '').toLowerCase();
if (!PLAN_PASO || !filasPaso(PLAN_PASO).length) PLAN_PASO = pasoPorDefecto();
pintarCabPlan();
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;
const partes = PASOS_PLAN.filter(p => porPaso[p.k].length).map(p => `${p.t.toLowerCase()} (${porPaso[p.k].length})`);
$('#planIntro').innerHTML = tot ? `<b class="grande">Qué va a hacer la app:</b> ${partes.join(', ')}. En total ${tot} cambio${tot > 1 ? 's' : ''} · ${gb(totB)}. <b>Todo se queda dentro de</b> <span class="path">${esc(PLAN.raiz)}</span> <b>y nada se borra.</b>${dud ? ` ${dud} cambio${dud > 1 ? 's son dudosos y quedan' : ' es dudoso y queda'} sin marcar.` : ''} Puedes aplicar cada paso por separado, en orden.` : `<b class="grande">La carpeta ya está en orden.</b> No hay nada que apartar ni mover.`;
$('#planPasos').innerHTML = PASOS_PLAN.map((p, i) => {
const rs = porPaso[p.k].filter(r => !f || (r.origen_rel + ' ' + r.destino_rel + ' ' + r.motivo).toLowerCase().includes(f)).sort((a, b) => ({alta: 0, media: 1, baja: 2}[a.confianza] - {alta: 0, media: 1, baja: 2}[b.confianza]) || b.bytes - a.bytes);
const marc = rs.filter(r => r.marcado), abierto = PLAN_ABIERTOS.has(p.k) || !!f;
const vacio = !porPaso[p.k].length;
let det = '';
if (abierto && rs.length) {
const grupos = {}; rs.forEach(r => (grupos[r.cubeta] = grupos[r.cubeta] || []).push(r));
det = Object.keys(grupos).map(g => `<div class="grp">${esc(NOMBRE_CUBETA[g] || g)} · ${grupos[g].length}</div>` + grupos[g].map(r => `<div class="fila ${r.confianza}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar" style="margin-top:2px"><div><div class="frase">${fraseDe(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="de">${esc(motivoLlano(r.motivo))}${r.n ? ` · ${num(r.n)} archivo${r.n > 1 ? 's' : ''} · ${gb(r.bytes)}` : ''}</div></div><div><span class="tag ${r.confianza}">${SEG_TXT[r.confianza] || r.confianza}</span> ${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div></div>`).join('')).join('');
}
return `<div class="paso ${vacio ? 'vacio' : ''} ${abierto ? 'abierto' : ''}" data-k="${p.k}"><div class="pc"><span class="num">${vacio ? '✓' : i + 1}</span><div class="tx"><b>${p.t}</b><div class="mut">${vacio ? p.vacio : p.q}</div></div><div class="der">${vacio ? '' : `<span class="res">${rs.length} cambio${rs.length > 1 ? 's' : ''} · ${gb(rs.reduce((a, r) => a + r.bytes, 0))}${marc.length < rs.length ? ` · ${marc.length} marcado${marc.length !== 1 ? 's' : ''}` : ''}</span><button class="lnk" data-ver="${p.k}">${abierto ? 'ocultar' : 'ver qué'}</button><button class="p" data-aplicar="${p.k}" ${marc.length ? '' : 'disabled'}>Aplicar${marc.length ? ' ' + marc.length : ''}</button>`}</div></div><div class="detalle">${det}</div></div>`;
}).join('');
$$('#planPasos [data-ver]').forEach(b => b.onclick = () => { const k = b.dataset.ver; if (PLAN_ABIERTOS.has(k)) PLAN_ABIERTOS.delete(k); else PLAN_ABIERTOS.add(k); pintarPlan(); });
$$('#planPasos [data-aplicar]').forEach(b => b.onclick = () => aplicarPaso(b.dataset.aplicar, b));
$$('#planPasos .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; actualizarContadores(); }; });
$$('#planPasos [data-ed]').forEach(b => b.onclick = () => { const r = PLAN.filas.find(x => x.id === +b.dataset.ed); modal('Cambiar destino', `<p>${fraseDe(r)}</p><p class="mut">Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.</p><input type="text" id="mDest" style="width:100%" value="${esc(r.destino_rel)}">`, seguro(async () => { const v = $('#mDest').value.trim(); if (!v) return; r.destino_rel = v; r.editado = true; r.marcado = true; if (!r.archivos) await api('/api/regla', {cliente: PLAN.cliente, origen: r.origen_rel, destino: v, cubeta: r.cubeta}); pintarPlan(); })); });
$('#planAvisos').innerHTML = avs.length ? `<details class="hint warn avisos" ${PLAN_AVISOS ? 'open' : ''}><summary><b>${avs.length === 1 ? 'Una cosa que conviene mirar antes de aplicar' : avs.length + ' cosas que conviene mirar antes de aplicar'}</b></summary><ul>${avs.map(a => `<li>${esc(a)}</li>`).join('')}</ul></details>` : '';
const dt = $('#planAvisos details'); if (dt) dt.ontoggle = () => PLAN_AVISOS = dt.open;
pintarEjemplo();
pintarLista();
estadoPlan();
}
$('#filtroPlan').oninput = pintarPlan;
// la frase de arriba y las tres fichas: cuánto hace cada paso y cuánto queda sin marcar
function pintarCabPlan(){
const tot = PLAN.filas.length, marc = PLAN.filas.filter(r => r.marcado), sin = PLAN.filas.filter(r => !r.marcado);
$('#planIntro').innerHTML = !tot ? `<b class="grande">La carpeta ya está en orden.</b> No hay nada que apartar ni mover.`
: !marc.length ? `<b class="grande">Lo seguro ya está hecho.</b> Quedan ${sin.length} cambio${sin.length > 1 ? 's' : ''} que la app no hace sola (${resumenOpc(sin)}). Están abajo, por si quieres marcarlos.`
: `<b class="grande">La app va a hacer ${marc.length} cambio${marc.length > 1 ? 's' : ''}</b> (${gb(marc.reduce((a, r) => a + r.bytes, 0))}) dentro de <span class="path">${esc(PLAN.raiz)}</span>. Nada se borra, y se deshace desde Historial.${sin.length ? ` Otros ${sin.length} quedan sin marcar (${resumenOpc(sin)}): están abajo, por si los quieres.` : ''}`;
$('#planPasos').innerHTML = PASOS_PLAN.map((p, i) => {
const rs = filasPaso(p.k), m = rs.filter(r => r.marcado), vacio = !rs.length, on = PLAN_PASO === p.k;
return `<div class="ficha ${vacio ? 'vacio' : ''} ${on ? 'on' : ''}" data-paso="${p.k}" ${vacio ? '' : `tabindex="0" role="button" aria-pressed="${on}"`}><span class="num">${vacio ? '✓' : i + 1}</span><div class="tx"><b>${p.t}</b><div class="q">${vacio ? p.vacio : p.q}</div>${vacio ? '' : `<div class="cifra">${m.length ? `<b>${m.length}</b> cambio${m.length > 1 ? 's' : ''} · ${gb(m.reduce((a, r) => a + r.bytes, 0))}` : '<b>0</b> marcados'}${rs.length > m.length ? ` <span class="mut">· ${rs.length - m.length} sin marcar</span>` : ''}</div>`}</div>${vacio || !m.length ? '' : `<button class="lnk" data-aplicar="${p.k}" title="Aplicar solo lo marcado de este paso">solo este paso</button>`}</div>`;
}).join('');
$$('#planPasos .ficha:not(.vacio)').forEach(f => { const ir = () => { if (PLAN_PASO !== f.dataset.paso) { PLAN_PASO = f.dataset.paso; PLAN_EJ = 0; PLAN_VER_OPC = false; pintarPlan(); } }; f.onclick = e => { if (!e.target.closest('button')) ir(); }; f.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } }; });
$$('#planPasos [data-aplicar]').forEach(b => b.onclick = () => aplicarPaso(b.dataset.aplicar, b));
}
// ---- el ejemplo: una fila del paso, dibujada como dos ramas de carpetas, y lo que se mueve volando de una a otra
function ejemplosDe(k){ return filasPaso(k).slice().sort((a, b) => (b.marcado - a.marcado) || ((b.accion !== 'renombrar') - (a.accion !== 'renombrar')) || (b.bytes - a.bytes) || (b.n - a.n)); }
function ejemploActual(){ const ej = ejemplosDe(PLAN_PASO); return ej.length ? ej[PLAN_EJ % ej.length] : null; }
function ramasDe(r){
const n = r.archivos ? r.archivos.length : 0, arch = `${n} archivo${n > 1 ? 's' : ''}`;
if (r.archivos && r.origen_rel === '(toda la raíz)') return {antes: [], despues: trozos(r.destino_rel), movil: {chip: true, antes: `${arch} de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}, por toda la carpeta`, despues: arch}};
if (r.archivos) return {antes: trozos(r.origen_rel), despues: trozos(r.destino_rel), movil: {chip: true, antes: arch, despues: arch}};
return {antes: trozos(padreDe(r.origen_rel)), despues: trozos(padreDe(r.destino_rel)), movil: {chip: false, antes: ultimo(r.origen_rel), despues: ultimo(r.destino_rel)}};
}
function ramaHtml(segs, movil, cual){
let h = `<div class="nivel raiz"><i class="fo"></i><span>${esc(ultimo(PLAN.raiz))}</span></div>`;
segs.forEach((s, i) => h += `<div class="nivel" style="--n:${i + 1}"><i class="fo"></i><span>${esc(s)}</span></div>`);
return h + `<div class="nivel" style="--n:${segs.length + 1}"><span class="mv ${movil.chip ? 'chip' : ''}">${movil.chip ? '' : '<i class="fo"></i>'}<span>${esc(movil[cual])}</span></span></div>`;
}
function pintarEjemplo(){
const caja = $('#planEjemplo'), ej = ejemplosDe(PLAN_PASO), r = ejemploActual();
if (!r) { caja.innerHTML = ''; return; }
const ramas = ramasDe(r), otros = filasPaso(PLAN_PASO).filter(x => x.marcado && x !== r).length, p = PASOS_PLAN.find(x => x.k === PLAN_PASO);
caja.innerHTML = `<div class="ejemplo" id="ejCaja"><div class="ejcab"><span class="cabsec" style="margin:0">${esc(p.t)} · con un ejemplo</span><span class="sep"></span>${ej.length > 1 ? `<span class="mut" style="font-size:12px">${(PLAN_EJ % ej.length) + 1} de ${ej.length}</span><button class="lnk" id="ejOtro">otro ejemplo</button>` : ''}<button class="lnk" id="ejOtra">ver otra vez</button></div>
<div class="ejcols"><div><div class="ejtit">Ahora</div><div class="ejarbol" id="ejAntes">${ramaHtml(ramas.antes, ramas.movil, 'antes')}</div></div><div class="ejflecha">→</div><div><div class="ejtit">Después</div><div class="ejarbol" id="ejDespues">${ramaHtml(ramas.despues, ramas.movil, 'despues')}</div></div></div>
<div class="ejtexto" id="ejTexto"><b>${fraseDe(r)}</b>${r.marcado ? '' : ` <span class="tag ${r.confianza}">sin marcar: ${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>`}<div class="pq">Por qué: ${esc(motivoLlano(r.motivo))}${r.n ? ` · ${num(r.n)} archivo${r.n > 1 ? 's' : ''} · ${gb(r.bytes)}` : ''}</div></div>
${otros ? `<div class="ejpie mut">Y lo mismo con ${otros === 1 ? 'el otro cambio de este paso, que está' : 'los otros ' + otros + ' cambios de este paso, que están'} en la lista de abajo. Un solo clic los hace todos.</div>` : ''}</div>`;
const o = $('#ejOtro'); if (o) o.onclick = () => { PLAN_EJ = (PLAN_EJ + 1) % ej.length; pintarEjemplo(); pintarLista(); };
$('#ejOtra').onclick = animarEjemplo;
animarEjemplo();
}
function animarEjemplo(){
const caja = $('#ejCaja'); if (!caja) return;
const a = caja.querySelector('#ejAntes .mv'), b = caja.querySelector('#ejDespues .mv'), tx = $('#ejTexto');
caja.querySelectorAll('.vuelo').forEach(x => x.remove());
a.classList.remove('fantasma'); b.classList.remove('llega'); b.style.visibility = ''; tx.classList.remove('visto');
const fin = () => { caja.querySelectorAll('.vuelo').forEach(x => x.remove()); b.style.visibility = ''; b.classList.add('llega'); a.classList.add('fantasma'); tx.classList.add('visto'); };
// si no se ve (otra pestaña) o Windows pide «menos movimiento», se enseña el resultado y ya
if (!caja.offsetParent || matchMedia('(prefers-reduced-motion: reduce)').matches) { fin(); return; }
const rc = caja.getBoundingClientRect(), ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
const v = a.cloneNode(true); v.classList.add('vuelo');
Object.assign(v.style, {position: 'absolute', left: (ra.left - rc.left) + 'px', top: (ra.top - rc.top) + 'px', margin: '0'});
caja.appendChild(v);
b.style.visibility = 'hidden'; a.classList.add('fantasma');
requestAnimationFrame(() => requestAnimationFrame(() => { v.style.transition = 'transform .9s cubic-bezier(.4,0,.2,1)'; v.style.transform = `translate(${rb.left - ra.left}px, ${rb.top - ra.top}px)`; }));
v.addEventListener('transitionend', fin, {once: true});
setTimeout(() => { if (v.isConnected) fin(); }, 1500);
}
// ---- la lista: de dónde → a dónde, con la cuenta; el porqué solo si se pide
function deHtml(r){
if (r.archivos && r.origen_rel === '(toda la raíz)') return `<b>${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}</b><span class="en">por toda la carpeta</span>`;
if (r.archivos) return `<b>${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''}</b><span class="en">en ${esc(r.origen_rel)}</span>`;
return `<b>${esc(ultimo(r.origen_rel))}</b><span class="en">en ${esc(padreDe(r.origen_rel) || 'la raíz')}</span>`;
}
function aHtml(r){
if (r.accion === 'renombrar') return `pasa a llamarse <b>${esc(ultimo(r.destino_rel))}</b>`;
if (!r.archivos && ultimo(r.destino_rel) === ultimo(r.origen_rel)) return esc(padreDe(r.destino_rel) || 'la raíz');
return esc(r.destino_rel);
}
function filaHtml(r, ej){
return `<div class="fila ${r.confianza} ${r.marcado ? '' : 'sinmarcar'} ${ej && ej.id === r.id ? 'esej' : ''}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"><div class="de">${deHtml(r)}</div><span class="fl">→</span><div class="a">${aHtml(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="meta">${r.n ? `${num(r.n)} arch. · ${gb(r.bytes)}` : ''}</div><div class="acts">${r.confianza !== 'alta' ? `<span class="tag ${r.confianza}">${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>` : ''}<button class="ed" data-ej="${r.id}" title="Verlo arriba, como ejemplo">ejemplo</button>${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div><div class="pq">${esc(motivoLlano(r.motivo))}</div></div>`;
}
function pintarLista(){
const caja = $('#planLista'), p = PASOS_PLAN.find(x => x.k === PLAN_PASO);
if (!p) { caja.innerHTML = ''; return; }
const f = ($('#filtroPlan').value || '').toLowerCase(), pasa = r => !f || (r.origen_rel + ' ' + r.destino_rel + ' ' + r.motivo).toLowerCase().includes(f);
const peso = {alta: 0, media: 1, baja: 2}, orden = (a, b) => (peso[a.confianza] - peso[b.confianza]) || b.bytes - a.bytes;
const todas = filasPaso(PLAN_PASO).filter(pasa).sort(orden), marc = todas.filter(r => r.marcado), sin = todas.filter(r => !r.marcado), ej = ejemploActual();
const grupos = rs => { const g = {}; rs.forEach(r => (g[r.cubeta] = g[r.cubeta] || []).push(r)); return Object.keys(g).map(c => `<div class="grp">${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}</div>` + g[c].map(r => filaHtml(r, ej)).join('')).join(''); };
const cos = sin.filter(r => r.cosmetico), dud = sin.filter(r => !r.cosmetico);
const explica = [cos.length ? `${cos.length === 1 ? 'uno solo cambia' : cos.length + ' solo cambian'} el nombre a la forma «NN- Título» (como «01 - Celular» → «01- Celular»): da igual hacerlo o no` : '', dud.length ? `${dud.length} ${dud.length === 1 ? 'es dudoso' : 'son dudosos'}: la app no está segura y no ${dud.length === 1 ? 'lo' : 'los'} mueve sin que lo marques tú` : ''].filter(Boolean).join('; ');
caja.innerHTML = `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`;
$('#chkPorQue').onchange = e => { PLAN_POR_QUE = e.target.checked; caja.querySelector('.lista').classList.toggle('porque', PLAN_POR_QUE); };
const bo = $('#btnVerOpc'); if (bo) bo.onclick = () => { PLAN_VER_OPC = !PLAN_VER_OPC; pintarLista(); };
const bc = $('#btnMarcarCosm'); if (bc) bc.onclick = () => { cos.forEach(r => r.marcado = true); pintarPlan(); };
// marcar o desmarcar una fila cambia las cifras, no la lista: así el foco se queda donde está
$$('#planLista .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; el.classList.toggle('sinmarcar', !r.marcado); pintarCabPlan(); estadoPlan(); }; });
$$('#planLista [data-ej]').forEach(b => b.onclick = () => { const i = ejemplosDe(PLAN_PASO).findIndex(x => x.id === +b.dataset.ej); if (i >= 0) { PLAN_EJ = i; pintarEjemplo(); pintarLista(); $('#ejCaja').scrollIntoView({behavior: 'smooth', block: 'nearest'}); } });
$$('#planLista [data-ed]').forEach(b => b.onclick = () => { const r = PLAN.filas.find(x => x.id === +b.dataset.ed); modal('Cambiar destino', `<p>${fraseDe(r)}</p><p class="mut">Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.</p><input type="text" id="mDest" style="width:100%" value="${esc(r.destino_rel)}">`, seguro(async () => { const v = $('#mDest').value.trim(); if (!v) return; r.destino_rel = v; r.editado = true; r.marcado = true; if (!r.archivos) await api('/api/regla', {cliente: PLAN.cliente, origen: r.origen_rel, destino: v, cubeta: r.cubeta}); pintarPlan(); })); });
}
$('#filtroPlan').oninput = pintarLista;
// ---- aplicar: la barra mientras se mueve, y al acabar qué se hizo y qué queda
const tiempoDe = s => s < 60 ? `${Math.max(1, Math.round(s))} s` : `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`;
function pintarAvance(s){
const caja = $('#planAvance');
if (s.activo) {
const u = s.unidades_total ? Math.min(100, Math.round(s.unidades * 100 / s.unidades_total)) : 0;
caja.innerHTML = `<div class="avance"><div class="l1"><span class="spin"></span><b>Moviendo…</b><span>${u} % · ${s.hechas || 0} de ${s.total} cambio${s.total > 1 ? 's' : ''}</span><span class="sep"></span><span class="mut">${num(s.movidos || 0)} movimiento${s.movidos === 1 ? '' : 's'}${s.fallos ? ` · <span class="bad">${s.fallos} no se pudieron</span>` : ''}</span></div><div class="bar grande"><i style="width:${u}%"></i></div><div class="l3 mut">${s.actual ? `ahora: «${esc(s.actual)}»${s.archivo ? ' · ' + esc(s.archivo) : ''}` : 'empezando…'}</div></div>`;
return;
}
const r = s.res, falt = r.errores.length;
caja.innerHTML = `<div class="avance listo ${falt ? 'conerr' : ''}"><div class="l1"><span class="ico">${falt ? '!' : '✓'}</span><b>${falt ? 'Hecho, con tropiezos.' : 'Listo.'}</b><span>${r.filas} de ${s.pedidas} cambio${s.pedidas > 1 ? 's' : ''} · ${num(r.movimientos)} movimiento${r.movimientos === 1 ? '' : 's'} · ${gb(s.bytes)} · ${tiempoDe(s.seg)}</span><span class="sep"></span>${r.lote ? `<button class="lnk" id="avDeshacer">Deshacer esto</button>` : ''}</div><div class="bar grande"><i style="width:100%"></i></div>${falt ? `<div class="l3 bad">${falt} no se pudieron mover. <button class="lnk" id="avErr" style="padding:0 4px">ver cuáles</button></div>` : ''}<div class="l3 mut" id="avQuedan"><span class="spin"></span> mirando qué queda…</div></div>`;
if (r.lote) $('#avDeshacer').onclick = () => deshacer(r.lote);
if (falt) $('#avErr').onclick = () => modal('No se pudieron mover', `<pre>${esc(r.errores.join('\n'))}</pre>`, null);
}
function pintarQueda(){
const el = $('#avQuedan'); if (!el || !PLAN) return;
const sin = PLAN.filas.filter(r => !r.marcado), marc = PLAN.filas.filter(r => r.marcado);
el.innerHTML = marc.length ? `Todavía hay ${marc.length} cambio${marc.length > 1 ? 's' : ''} marcado${marc.length > 1 ? 's' : ''} por aplicar.` : sin.length ? `Queda sin hacer: ${sin.length} cambio${sin.length > 1 ? 's' : ''} que la app no hace sola (${resumenOpc(sin)}). Están abajo, por si los quieres.` : 'No queda nada pendiente: la carpeta está en orden.';
}
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> 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 bytes = m.reduce((a, r) => a + r.bytes, 0);
modal(titulo, `<p>Se hace${m.length > 1 ? 'n' : ''} <b>${m.length}</b> cambio${m.length > 1 ? 's' : ''} (${gb(bytes)}) dentro de <span class="path">${esc(PLAN.raiz)}</span>, seguido${m.length > 1 ? 's' : ''} y sin preguntar más.</p><p><b>Nada se borra</b> y se deshace desde Historial. <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});
const raiz = PLAN.raiz, t0 = Date.now();
pintarAvance({activo: true, total: m.length, hechas: 0, unidades: 0, unidades_total: 0});
$('#planAvance').scrollIntoView({behavior: 'smooth', block: 'nearest'});
// mientras el servidor mueve, se le pregunta por dónde va (archivo a archivo); lo de una tanda anterior no vale
const reloj = setInterval(async () => { try { const a = await api('/api/aplicando'); if (a.inicio && a.inicio * 1000 >= t0 - 2000 && !a.fin) pintarAvance({activo: true, ...a, total: m.length}); } catch (e) {} }, 400);
let res;
try { res = await api('/api/aplicar', {raiz, ids: m.map(r => r.id), destinos}); } catch (e) { clearInterval(reloj); $('#planAvance').innerHTML = ''; throw e; }
clearInterval(reloj);
PLAN = null; MAPA = null;
pintarAvance({activo: false, res, seg: (Date.now() - t0) / 1000, pedidas: m.length, bytes});
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(); pintarAcciones().catch(() => {});
await cargarEstado(); await calcularPlan(); pintarQueda(); pintarAcciones().catch(() => {});
}));
$('#modalSi').textContent = 'Aplicar';
}
function aplicarPaso(k, btn){ const p = PASOS_PLAN.find(x => x.k === k); aplicarFilas(PLAN.filas.filter(r => (r.paso || 'resto') === k && r.marcado), btn, 'Paso: ' + p.t); }
function aplicarPaso(k, btn){ const p = PASOS_PLAN.find(x => x.k === k); aplicarFilas(PLAN.filas.filter(r => pasoDe(r) === k && r.marcado), btn, 'Paso: ' + p.t); }
$('#btnAplicarSeguros').onclick = () => aplicarFilas(PLAN.filas.filter(r => r.marcado), $('#btnAplicarSeguros'), 'Aplicar todo lo marcado');
$('#btnArbol').onclick = () => { const arbol = {}; PLAN.filas.filter(r => r.marcado).forEach(r => { const partes = r.destino_rel.split(/[\\/]/); let n = arbol; partes.forEach(p => { n[p] = n[p] || {}; n = n[p]; }); }); const lineas = []; const dibuja = (n, pref) => { const ks = Object.keys(n).sort(); ks.forEach((k, i) => { const ult = i === ks.length - 1; lineas.push(pref + (ult ? '└─ ' : '├─ ') + k); dibuja(n[k], pref + (ult ? ' ' : '│ ')); }); }; dibuja(arbol, ''); modal('Cómo quedaría (solo lo marcado)', `<div class="tree">${esc(PLAN.raiz)}\n${esc(lineas.join('\n'))}</div>`, null); };