Files
labstream-organizador/static/index.html
Labstream 1151320bf2 2.6.0: auditoría, 100 % local y 26 funciones verificadas
Sale a la red solo lo que se pide: la tipografía viene con el programa
(static/manrope.woff2) y el comprobador de versiones tiene interruptor en
Ajustes. Nueva prueba funcional (prueba_funcional.py) que levanta una copia
sobre un cliente de ensayo y ejercita las 26 funciones por su API, con una
conversión real: 26 de 26.

Fallos que sacó y quedan arreglados:
- Un renombrado solo de mayúsculas («10- octubre» → «10- Octubre») se
  descartaba en Plan.agregar y en mover; deshacer decía «sitio ocupado».
- Las carpetas que crea el plan no entraban en el inventario hasta el
  siguiente escaneo (_recontar_carpetas las da de alta y recuenta).
- Una fusión de carpetas cortaba en el primer archivo que tropezaba.
- «Viejo» era distinto en convertidor, stock y respaldo: ahora es_viejo.
- /api/actualizacion informaba versión vacía hasta la primera consulta.
- Filas de sueltos por carpeta de origen; mes del stock el más repetido;
  fuera tres rutas sin uso.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 20:57:41 -05:00

1669 líneas
173 KiB
HTML
Original Blame Histórico

Este archivo contiene caracteres Unicode ambiguos

Este archivo contiene caracteres Unicode que pueden confundirse con otros caracteres. Si crees que esto es intencional, puedes ignorar esta advertencia. Usa el botón de Escape para revelarlos.

<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OrganiLAB</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231f57d6'/%3E%3Crect x='7' y='8' width='18' height='4' rx='2' fill='%23fff'/%3E%3Crect x='7' y='14' width='13' height='4' rx='2' fill='%23fff' opacity='.85'/%3E%3Crect x='7' y='20' width='8' height='4' rx='2' fill='%23fff' opacity='.65'/%3E%3C/svg%3E">
<style>@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/static/manrope.woff2) format('woff2')}</style>
<style>
:root{--bg:#ffffff;--panel:#ffffff;--panel2:#f3f4f5;--line:#e6e7e8;--line2:#f0f1f2;--tx:#17191c;--mut:#6d7076;--acc:#1f57d6;--acc2:#e8eefc;--ok:#2f8f5b;--ok2:#e6f2ea;--warn:#9a6d12;--warn2:#fbf1d9;--bad:#b3473e;--bad2:#f7e6e4}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--bg);color:var(--tx);font:13.5px/1.5 Manrope,system-ui,-apple-system,"Segoe UI",sans-serif;display:grid;grid-template-columns:250px 1fr;grid-template-rows:1fr auto;min-height:100vh}
a{color:var(--acc)}
select,input[type=text],input[type=number],textarea{background:var(--panel);color:var(--tx);border:1px solid var(--line);border-radius:5px;padding:5px 8px;font:inherit}
input[type=text]{min-width:120px}
button{background:var(--panel);color:var(--tx);border:1px solid #d5d8dc;border-radius:5px;padding:5px 11px;font:inherit;font-weight:600;cursor:pointer}
button:hover{border-color:var(--tx)}
button:focus-visible,a:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
button.p{background:var(--acc);border-color:var(--acc);color:#fff}
button.p:hover{background:#1747b3;border-color:#1747b3}
button.ok{background:var(--ok);border-color:var(--ok);color:#fff}
button.bad{background:transparent;border-color:var(--bad);color:var(--bad)}
button.lnk{border-color:transparent;color:var(--acc);padding:5px 6px;font-weight:600}
button.lnk:hover{border-color:transparent;text-decoration:underline}
button:disabled{opacity:.45;cursor:default;border-color:var(--line)!important;background:var(--panel2)!important;color:var(--mut)!important}
button .spin{margin-right:6px}
.por-que{font-size:12px;color:var(--mut);margin-left:2px}
/* ── lateral ── */
aside{grid-row:1/3;border-right:1px solid var(--line);padding:20px 16px 16px;display:flex;flex-direction:column;gap:4px;position:sticky;top:0;height:100vh;overflow:auto}
.marca{display:flex;align-items:center;gap:10px;margin-bottom:16px}
.marca svg{width:28px;height:28px;flex:none}
.marca .n{font-weight:700;font-size:18px;letter-spacing:-.02em;line-height:1}
.marca .n b{color:var(--acc);font-weight:700}
.marca .s{font-size:10.5px;color:var(--mut);letter-spacing:.06em;text-transform:uppercase;margin-top:3px}
.lado-tit{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);margin:4px 0 4px;font-weight:600}
.cl{display:block;padding:7px 8px;margin:0 -8px;border-radius:6px;cursor:pointer}
.cl:hover{background:var(--panel2)}
.cl.on{background:var(--acc2)}
.cl .n{display:flex;justify-content:space-between;gap:8px;align-items:baseline;font-weight:600;font-size:13.5px}
.cl.on .n{color:var(--acc)}
.cl em{font-style:normal;color:var(--mut);font-size:11.5px;font-variant-numeric:tabular-nums;white-space:nowrap;font-weight:500}
.cl .e{font-size:11.5px;color:var(--mut);margin-top:1px;display:flex;gap:6px;align-items:center}
.cl .x{margin-left:auto;border:0;background:none;color:var(--mut);font-size:13px;line-height:1;padding:0 4px;border-radius:4px;cursor:pointer;opacity:0;visibility:hidden}
.cl:hover .x,.cl.on .x,.cl:focus-within .x{opacity:1;visibility:visible}
.cl .x:hover{color:var(--bad);background:#fff}
.pto{width:7px;height:7px;border-radius:50%;background:#c7cbd1;display:inline-block;flex:none}
.pto.ok{background:var(--ok)} .pto.run{background:var(--acc);animation:pulso 1.2s ease-in-out infinite} .pto.cur{background:var(--acc)} .pto.warn{background:var(--warn)}
@keyframes pulso{0%,100%{opacity:1}50%{opacity:.25}}
.riel{margin:6px 0 2px 3px;padding-left:9px;border-left:2px solid var(--line);display:flex;flex-direction:column;gap:1px}
.riel div{display:flex;gap:7px;align-items:center;font-size:12.5px;color:var(--mut);padding:3px 6px;margin-left:-6px;border-radius:4px;cursor:pointer}
.riel div:hover{background:#fff}
.riel div.ok{color:var(--tx)} .riel div.cur{color:var(--acc);font-weight:700} .riel div.aqui{background:#fff}
.riel div small{margin-left:auto;font-size:11px;color:var(--mut);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:78px}
.riel div.bloq{opacity:.55;cursor:default}
#zonaSoltar{margin-top:12px;border:1px dashed #c7cbd1;border-radius:6px;padding:11px 10px;color:var(--mut);font-size:12px;cursor:pointer;text-align:center;line-height:1.35}
#zonaSoltar.sobre{border-color:var(--acc);color:var(--acc);background:var(--acc2)}
.lado-pie{margin-top:auto;padding-top:14px;border-top:1px solid var(--line);font-size:11.5px;color:var(--mut);display:flex;flex-direction:column;gap:6px}
.lado-pie img{height:14px;width:auto;display:block;opacity:.9;align-self:flex-start}
.lado-pie button{padding:4px 9px;font-size:12px;align-self:flex-start;font-weight:500}
#raiz{display:none}
/* ── principal ── */
#principal{min-width:0}
nav{display:flex;gap:2px;padding:0 28px;border-bottom:1px solid var(--line);align-items:center;position:sticky;top:0;background:var(--bg);z-index:5}
nav a{padding:13px 11px;color:var(--mut);text-decoration:none;border-bottom:2px solid transparent;cursor:pointer;font-weight:600}
nav a.on{color:var(--tx);border-color:var(--tx)}
nav a.eng{margin-left:auto;font-size:12.5px;font-weight:500}
main{padding:22px 28px 40px;max-width:1720px;overflow-x:hidden}
section{display:none}section.on{display:block}
h2.v{margin:0 0 2px;font-size:18px;font-weight:700;letter-spacing:-.01em}
.cab{display:flex;justify-content:space-between;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
.cab .sub{color:var(--mut);font-size:12.5px}
.cab .sub .path{font-size:11.5px}
.cab .der{display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-left:auto}
/* ayuda plegable: una línea visible, el resto tras «?» */
.ayuda{font-size:12.5px;color:var(--mut);margin:0 0 14px;max-width:90ch}
.ayuda b{color:var(--tx);font-weight:600}
.ayuda .q{display:inline-flex;width:16px;height:16px;border-radius:50%;border:1px solid #c7cbd1;color:var(--mut);align-items:center;justify-content:center;font-size:10.5px;font-weight:700;cursor:pointer;margin-left:4px;vertical-align:-3px;user-select:none}
.ayuda .q:hover{border-color:var(--tx);color:var(--tx)}
.ayuda .mas{display:none;margin-top:4px;padding:8px 10px;background:var(--panel2);border-radius:5px;color:var(--tx)}
.ayuda.abierta .mas{display:block}
/* pestañas internas */
.tabs{display:flex;gap:18px;border-bottom:1px solid var(--line);margin:8px 0 16px;font-size:13.5px}
.tabs a{padding:6px 0 8px;color:var(--mut);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;font-weight:600;display:inline-flex;gap:6px;align-items:center}
.tabs a.on{color:var(--tx);border-color:var(--tx)}
.tabs .badge{background:var(--warn2);color:var(--warn);border-radius:99px;padding:0 7px;font-size:11px;font-weight:700}
.tabs .badge.cero{background:var(--ok2);color:var(--ok)}
.sub{display:none}.sub.on{display:block}
/* carpeta */
.sig{border:1px solid var(--line);border-left:3px solid var(--acc);border-radius:0 8px 8px 0;padding:14px 16px;margin:12px 0 18px;background:#fbfcfe}
.sig h3{margin:0 0 3px;font-size:15px;font-weight:700}
.sig p{margin:0 0 10px;color:var(--mut);font-size:13px;max-width:80ch}
.sig .acts{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.sig .acts .nada{font-size:12.5px;color:var(--mut)}
.vacio-caja{border:2px dashed #c7cbd1;border-radius:10px;padding:56px 32px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s}
.vacio-caja.sobre,.vacio-caja:hover{border-color:var(--acc);background:var(--acc2)}
.vacio-caja h2{margin:0 0 8px;font-size:20px;font-weight:700;letter-spacing:-.02em}
.vacio-caja p{margin:0;color:var(--mut);max-width:48ch;margin-inline:auto}
.vacio-caja .o{margin-top:16px;font-size:13px;color:var(--mut)}
.vacio-caja .o u{color:var(--acc);text-decoration:none;border-bottom:1px solid var(--acc)}
.vacio-pasos{display:grid;grid-template-columns:repeat(5,1fr);gap:16px;margin-top:24px;font-size:12.5px}
.vacio-pasos b{display:block;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);margin-bottom:4px}
@media(max-width:900px){.vacio-pasos{grid-template-columns:1fr 1fr}}
.peso{display:flex;height:14px;margin:4px 0 8px;border-radius:4px;overflow:hidden;gap:1px;background:var(--panel2)}
.peso i{display:block;height:100%;min-width:2px;cursor:default}
.peso-linea{font-size:12.5px;color:var(--mut)}
.peso-linea b{color:var(--tx);font-variant-numeric:tabular-nums}
/* bloques genéricos */
.cards{display:grid;grid-template-columns:repeat(8,1fr);gap:10px;margin-bottom:14px}
.cards.c4{grid-template-columns:repeat(4,1fr)}
@media(max-width:1150px){.cards{grid-template-columns:repeat(4,1fr)}}
.card{border-bottom:1px solid var(--line);padding:4px 0 8px}
.card b{display:block;font-size:19px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
.card small{color:var(--mut);font-size:11.5px}
.row{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:18px}
.row>*{min-width:0}
@media(max-width:1000px){.row{grid-template-columns:1fr}}
.box{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:14px;overflow-x:auto}
.box h3{margin:0 0 8px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);font-weight:600}
.box.plano{border:0;padding:0;overflow:visible}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.06em}
td.num,th.num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}
.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
.tag{display:inline-block;padding:1px 7px;border-radius:99px;font-size:11px;border:1px solid var(--line);color:var(--mut);white-space:nowrap}
.tag.alta{color:var(--ok);border-color:var(--ok)}.tag.media{color:var(--warn);border-color:var(--warn)}.tag.baja{color:var(--bad);border-color:var(--bad)}
.bar{height:7px;background:var(--panel2);border-radius:99px;overflow:hidden}.bar i{display:block;height:100%;background:var(--acc)}
.tm{position:relative;height:300px;border-radius:6px;overflow:hidden;background:var(--panel2)}
.tm div{position:absolute;overflow:hidden;padding:4px 6px;font-size:11px;border:1px solid rgba(255,255,255,.6);color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5);cursor:default}
.tm div b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.toolbar label{color:var(--mut);font-size:12px;display:flex;align-items:center;gap:6px}
.toolbar .sep{flex:1}
.path{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;word-break:break-all}
.arrow{color:var(--mut)}
.dest{width:100%;font-family:ui-monospace,Consolas,monospace;font-size:11.5px}
tr.grp td{background:var(--panel2);cursor:pointer;font-size:12.5px;padding:8px}
tr.grp td b{color:var(--tx)}
tr.grp .caret{display:inline-block;width:14px;color:var(--mut)}
tr.grp .cnt{color:var(--mut);margin-left:8px}
.modal{position:fixed;inset:0;background:rgba(23,25,28,.45);display:none;align-items:center;justify-content:center;z-index:20}
.modal.on{display:flex}
.modal .box{max-width:640px;width:92%;max-height:90vh;overflow:auto;box-shadow:0 20px 60px rgba(0,0,0,.18)}
.modal h3{font-size:15px;text-transform:none;letter-spacing:0;color:var(--tx);font-weight:700}
.preg{margin:14px 0 0}
.preg>label{display:block;font-weight:600;margin-bottom:5px}
.preg>span{display:block;color:var(--mut);font-size:12px;margin-top:4px}
.ejtipo{display:grid;gap:5px;margin:6px 0 0;padding:10px 12px;background:var(--panel2);border-radius:8px}
.ejtipo div{display:flex;gap:4px 10px;align-items:baseline;flex-wrap:wrap}
.ejtipo b{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;font-weight:600;word-break:break-all}
.ejtipo span{font-size:12px;color:var(--mut)}
.arbol{font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.55}
.arbol .nd{display:flex;gap:10px;align-items:baseline;padding:3px 7px;border-radius:5px;cursor:pointer}
.arbol .nd:hover{background:var(--panel2)}
.arbol .nd.sel{background:var(--acc2);color:var(--acc)}
.arbol .nd .nm{font-weight:600}
.arbol .nd.var .nm{font-style:italic;font-weight:500}
.arbol .leyenda{margin-top:10px;font-family:Manrope,system-ui,sans-serif;font-size:11.5px;color:var(--mut)}
.arbol .nd .cif{margin-left:auto;font-family:Manrope,system-ui,sans-serif;font-size:11px;color:var(--mut);white-space:nowrap}
.arbol .hijos{margin-left:9px;border-left:1px solid var(--line);padding-left:11px}
.arbol .vacio{color:var(--mut);font-weight:400}
.arbol .pt{width:9px;height:9px;border-radius:3px;flex:none;background:var(--line)}
.arbol .nd.hit{background:var(--ok2);outline:1px solid var(--ok)}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0}
.chips button{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;padding:3px 8px}
.resp{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:10px 12px;border-radius:8px;background:var(--panel2)}
.resp .pt{width:12px;height:12px;border-radius:4px;flex:none;margin-top:3px}
.resp .d{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all;font-weight:600}
.leyenda-col{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:10px;font-size:12px;color:var(--mut)}
.leyenda-col span{display:flex;align-items:center;gap:6px}
.leyenda-col i{width:10px;height:10px;border-radius:3px;display:inline-block}
.det h4{margin:0 0 4px;font-size:13.5px}
.det .ruta{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;word-break:break-all;color:var(--mut);margin-bottom:10px}
.det ul{margin:4px 0 0;padding-left:18px;font-size:12.5px}
.det .vars{display:grid;gap:3px;margin-top:6px;font-size:11.5px;color:var(--mut)}
.det .vars code{font-family:ui-monospace,Consolas,monospace;color:var(--tx)}
.sinsitio{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--line2)}
pre{background:var(--panel2);padding:10px;border-radius:6px;overflow:auto;font-size:11.5px;max-height:280px}
.tree{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;white-space:pre;line-height:1.5;overflow:auto;max-height:420px}
.hint{background:var(--acc2);border:0;border-left:3px solid var(--acc);border-radius:0 6px 6px 0;padding:9px 12px;color:var(--tx);font-size:12.5px;margin-bottom:14px}
.hint.warn{background:var(--warn2);border-left-color:var(--warn)}
.cell-small{font-size:11px;color:var(--mut)}
.chip{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:1px 6px;margin:2px 3px 0 0;font-size:11px}
.legend span{display:inline-flex;align-items:center;gap:5px;margin-right:12px;font-size:11.5px;color:var(--mut)}
.legend i{width:10px;height:10px;border-radius:2px;display:inline-block}
.spin{display:inline-block;width:12px;height:12px;border:2px solid var(--line);border-top-color:var(--acc);border-radius:50%;animation:sp 1s linear infinite;vertical-align:-2px}
@keyframes sp{to{transform:rotate(360deg)}}
.ejemplos li{font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--mut)}
.barra{height:7px;background:var(--panel2);border-radius:4px;overflow:hidden;width:200px;display:inline-block;vertical-align:middle}
.barra i{display:block;height:100%;background:var(--acc);transition:width .3s;min-width:2px}
.barra.ind i{width:30%!important;animation:ind 1.2s infinite linear}
@keyframes ind{from{margin-left:-30%}to{margin-left:100%}}
.asis-diag{display:grid;grid-template-columns:auto auto 1fr;gap:4px 12px;font-size:12.5px;margin:8px 0}
.asis-diag b{text-align:right;font-variant-numeric:tabular-nums}
.asis-paso{border:1px solid var(--line);border-radius:6px;padding:9px 12px;margin:8px 0}
.asis-paso h4{margin:0 0 4px;font-size:13.5px}
.asis-paso label{display:block;margin:4px 0;cursor:pointer}
.asis-frase{color:var(--acc)}
.colabar{display:flex;gap:12px;align-items:center;padding:10px 12px;background:var(--acc2);border-radius:6px;margin-bottom:10px;font-size:13px}
.colabar .b{flex:1;height:8px;background:#fff;border-radius:99px;overflow:hidden}
.colabar .b i{display:block;height:100%;background:var(--acc)}
.colabar b{white-space:nowrap}
.est{display:inline-block;padding:1px 8px;border-radius:99px;font-size:11px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
.est.pendiente,.est.cancelado{background:var(--panel2);color:var(--mut)}
.est.corriendo{background:var(--acc2);color:var(--acc)}
.est.listo,.est.verificado,.est.reemplazado{background:var(--ok2);color:var(--ok)}
.est.error{background:var(--bad2);color:var(--bad)}
.est.candidato{background:#fff;color:var(--mut);border:1px solid var(--line)}
.actualizacion{border-color:var(--acc)!important;background:var(--acc2)!important;margin-bottom:18px}
/* nombrar */
.nombrar{list-style:none;margin:0;padding:0}
.acc{display:flex;flex-direction:column;gap:8px;margin-bottom:18px}
.ac{display:grid;grid-template-columns:26px minmax(0,1fr) auto;gap:14px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px}
.ac.hecho{background:transparent;border-style:dashed;opacity:.62}
.ac.espera{opacity:.72}
.ac .ico{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;flex:none;background:var(--acc2);color:var(--acc)}
.ac.hecho .ico{background:var(--ok2);color:var(--ok)}
.ac.espera .ico{background:var(--panel2);color:var(--mut)}
.ac .tx{min-width:0}
.ac .tx b{font-size:14px;display:block}
.ac .tx .q{font-size:12.5px;color:var(--mut);margin-top:1px}
.ac .tx .d{font-size:12px;color:var(--mut);margin-top:3px}
.ac .der{display:flex;flex-direction:column;align-items:flex-end;gap:5px;flex:none}
.ac .cifra{font-size:13px;font-weight:700;white-space:nowrap;font-variant-numeric:tabular-nums}
.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}
.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}
.nombrar li .sg b{display:inline}
.nombrar li .acts button{white-space:nowrap}
.nombrar li .acts{display:flex;gap:6px;align-items:center;justify-content:flex-end}
.nombrar li .acts select{max-width:230px}
.stk-grp td{background:var(--panel2);font-weight:600;font-size:12.5px}
.nombrar li{display:grid;grid-template-columns:minmax(0,1fr) 260px 130px;gap:10px;align-items:center;padding:7px 0;border-bottom:1px solid var(--line);font-size:13px}
.nombrar li b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.nombrar li small{color:var(--mut);white-space:nowrap}
.nombrar li .r{font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
details.mas>summary{cursor:pointer;color:var(--mut);font-size:12.5px;margin:8px 0}
/* convertir */
.cfila{display:grid;grid-template-columns:24px 110px 1fr 150px 80px;gap:10px;padding:7px 0;border-bottom:1px solid var(--line2);font-size:12.5px;align-items:center}
.cfila:last-child{border-bottom:0}
.cfila .nom{min-width:0}
.cfila .nom .clip{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
.cfila .nom .sub2{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;color:var(--mut);margin-top:1px}
.cfila .tam{color:var(--mut);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
.cfila .ah{font-variant-numeric:tabular-nums;text-align:right;font-weight:600;color:var(--ok);white-space:nowrap}
.cfila .bar{margin-top:4px;height:5px}
.cfila.exc{opacity:.55}
.cabsec{display:flex;gap:10px;align-items:center;margin:14px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);font-weight:600}
.cabsec .sep{flex:1;height:1px;background:var(--line)}
details.ajc{display:inline}
details.ajc>summary{display:inline;cursor:pointer;color:var(--acc);list-style:none}
details.ajc>summary::-webkit-details-marker{display:none}
details.ajc .toolbar{margin:10px 0 0;padding:10px 12px;background:var(--panel2);border-radius:6px}
/* historial */
.dia{font-weight:700;font-size:13px;margin:14px 0 2px}
.ev{display:grid;grid-template-columns:52px 1fr auto;gap:12px;padding:7px 0;border-bottom:1px solid var(--line2);align-items:start;font-size:13px}
.ev .h{color:var(--mut);font-variant-numeric:tabular-nums}
.ev .q b{font-weight:600}
.ev .q .cell-small{margin-top:2px}
.ev details summary{cursor:pointer;color:var(--mut);font-size:11.5px;margin-top:2px}
.ev button{padding:3px 9px;font-size:12px}
/* ajustes */
.cli{border:1px solid var(--line);border-radius:6px;margin-bottom:8px}
.cli .cab2{display:flex;gap:10px;align-items:center;padding:10px 12px;cursor:pointer}
.cli .cab2 b{font-size:13.5px}
.cli .cab2 .tipo{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:99px;padding:0 7px}
.cli .cab2 .m{margin-left:auto;font-size:12px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
.cli .cab2 .caret{color:var(--mut);width:14px;text-align:center}
.cli .cuerpo{display:none;padding:4px 12px 12px;border-top:1px solid var(--line2)}
.cli.abierto{border-color:var(--acc)} .cli.abierto .cuerpo{display:block}
.cli .f{display:grid;grid-template-columns:150px 1fr;gap:8px 12px;align-items:start;margin-top:10px;font-size:12.5px}
.cli .f label{color:var(--mut);padding-top:6px}
.cli textarea{width:100%;min-height:54px;resize:vertical;font-family:ui-monospace,Consolas,monospace;font-size:12px}
.cli input[type=text]{width:100%}
/* barra de tareas */
#tareas{grid-column:2;position:sticky;bottom:0;background:var(--bg);border-top:1px solid var(--line);padding:7px 28px;display:none;gap:10px;flex-wrap:wrap;align-items:center;z-index:6;box-shadow:0 -6px 18px rgba(0,0,0,.04)}
#tareas.on{display:flex}
#tareas .tit{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);font-weight:600;margin-right:4px}
.tarea{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:6px;padding:5px 10px;font-size:12px;background:var(--panel);min-width:260px;max-width:460px}
.tarea.run{border-color:var(--acc)}
.tarea .q{font-weight:600;white-space:nowrap}
.tarea .w{min-width:0;flex:1}
.tarea .w .d{color:var(--mut);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tarea .b{height:5px;background:var(--panel2);border-radius:3px;overflow:hidden;margin-top:4px}
.tarea .b i{display:block;height:100%;background:var(--acc);min-width:2px;transition:width .3s}
.tarea .b.ind i{width:30%!important;animation:ind 1.2s infinite linear}
.tarea .pct{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap}
.tarea .pct small{display:block;font-size:11px;color:var(--mut);font-weight:500;text-align:right}
.tarea button{padding:3px 8px;font-size:11.5px}
/* avisos */
#avisos{position:fixed;right:18px;bottom:60px;display:flex;flex-direction:column;gap:8px;z-index:30;max-width:420px}
.aviso{background:var(--tx);color:#fff;border-radius:7px;padding:10px 14px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.18);display:flex;gap:10px;align-items:flex-start}
.aviso.bad{background:var(--bad)} .aviso.ok{background:var(--ok)}
.aviso a{color:#fff;font-weight:600;white-space:nowrap;cursor:pointer;text-decoration:underline}
.aviso .x{cursor:pointer;opacity:.7;margin-left:auto}
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
</style>
</head>
<body>
<aside id="lado">
<div class="marca">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="#1f57d6"/><rect x="7" y="8" width="18" height="4" rx="2" fill="#fff"/><rect x="7" y="14" width="13" height="4" rx="2" fill="#fff" opacity=".85"/><rect x="7" y="20" width="8" height="4" rx="2" fill="#fff" opacity=".65"/></svg>
<div><div class="n">Organi<b>LAB</b></div><div class="s">material en orden</div></div>
</div>
<div class="lado-tit">Carpetas de clientes</div>
<div id="indice"></div>
<select id="raiz" aria-hidden="true"></select>
<div id="zonaSoltar" role="button" tabindex="0" title="Suelta aquí una carpeta de cliente para añadirla, o haz clic para pegar la ruta">Suelta aquí la carpeta<br>de un cliente para añadirla</div>
<button class="lnk" id="btnElegir" style="width:100%;margin-top:4px" title="Abre el selector de carpetas de Windows">Elegir carpeta…</button>
<div class="lado-pie"><img src="/static/labstream.png" alt="Labstream Studio"><span id="reloj"></span><button id="btnBuscarAct" title="Consulta Labstream OS y avisa si hay una versión nueva">Buscar actualizaciones</button><span id="actUltima" class="mut" style="font-size:11px"></span></div>
</aside>
<div id="principal">
<nav id="nav" aria-label="Vistas">
<a data-t="carpeta" class="on" tabindex="0">Carpeta</a>
<a data-t="revisar" tabindex="0">Revisar y ordenar</a>
<a data-t="mapa" tabindex="0" title="Cómo van a quedar las carpetas y qué va en cada una">Mapa</a>
<a data-t="convertir" tabindex="0">Convertidor</a>
<a data-t="respaldar" tabindex="0">Respaldar</a>
<a data-t="historial" tabindex="0">Historial</a>
<a data-t="ajustes" class="eng" tabindex="0" title="Clientes, sus carpetas y plantillas">⚙ Ajustes</a>
</nav>
<main>
<!-- ============ CARPETA ============ -->
<section id="t-carpeta" class="on">
<div class="box" id="actualizacionCaja" style="display:none"></div>
<div id="inicioVacio" style="display:none">
<div class="vacio-caja" id="vacioCaja" role="button" tabindex="0">
<h2>Suelta aquí la carpeta de un cliente</h2>
<p>OrganiLAB la escanea, te enseña qué pesa y te lleva paso a paso. No mueve nada sin que lo confirmes, y nunca borra.</p>
<div class="o"><button class="p" id="btnElegir2" type="button">Elegir carpeta…</button> &nbsp; o <u>pega la ruta</u></div>
</div>
<div class="vacio-pasos">
<div><b>1 · Escanear</b>Lee nombres, tamaños y fechas. Solo lectura.</div>
<div><b>2 · Revisar</b>Qué pesa: BRAW, cámara, stock, proxies, basura, repetidos.</div>
<div><b>3 · Ordenar</b>Mueve dentro de la misma carpeta. Lo que sobra va a _Para borrar.</div>
<div><b>4 · Convertir</b>Clips ya entregados a un formato más ligero (H.265 10 bits, H.264, ProRes), en cola uno tras otro.</div>
<div><b>5 · Respaldar</b>Copia compacta a otro disco, con índice.</div>
</div>
</div>
<div id="inicioLleno">
<div class="cab"><div><h2 class="v" id="cabTitulo">…</h2><div class="sub" id="cabSub"></div></div><div class="der"><button class="lnk" id="btnEscanear">Volver a escanear</button><button class="lnk" id="btnDesconectar" title="La carpeta deja de aparecer en la lista de la izquierda. No borra ni mueve nada del disco.">Quitar de la lista</button></div></div>
<div id="acciones" class="acc"></div>
<div class="sig" id="siguiente" style="display:none"></div>
<div class="row">
<div class="box plano"><h3>Qué hay en la carpeta</h3><div id="pesoBarra" class="peso"></div><div id="pesoLinea" class="peso-linea"></div><div id="inicioDiag" style="margin-top:8px;font-size:12.5px"></div></div>
<div class="box plano"><h3>Últimos cambios en esta carpeta</h3><div id="inicioLotes" class="mut">Todavía no se ha movido nada.</div></div>
</div>
</div>
</section>
<!-- ============ REVISAR Y ORDENAR ============ -->
<section id="t-revisar">
<h2 class="v">Revisar y ordenar</h2>
<p class="ayuda">Todo se mueve <b>dentro de la misma carpeta</b>, y lo único que se borra son las carpetas que se quedan vacías, si las marcas tú. <span class="q" title="Más">?</span><span class="mas">Basura y proxies van a <span class="path">_Para borrar</span>. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.</span></p>
<div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div>
<div id="revisarLleno" style="display:none">
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a></div>
<div class="sub on" id="s-hay">
<div class="cards" id="cards"></div>
<div class="row">
<div class="box"><h3>Qué pesa (primer nivel)</h3><div class="tm" id="treemap"></div><div class="legend" id="legend" style="margin-top:8px"></div></div>
<div class="box"><h3>Avisos</h3><div id="alertas"></div></div>
</div>
<details class="mas"><summary>Más detalle: cámaras, meses, tipos de archivo, repetidos, dónde vive cada cliente</summary>
<div class="row" style="margin-top:12px"><div class="box"><h3>Por fuente (de qué cámara viene)</h3><div id="fuentes"></div></div><div class="box"><h3>Material de cámara por mes</h3><div id="meses"></div></div></div>
<div class="row"><div class="box"><h3>Por tipo de archivo</h3><div id="exts"></div></div><div class="box"><h3>Repetidos (mismo nombre y peso, o el mismo clip en MP4 y MOV)</h3><div id="dups"></div></div></div>
<div class="box" style="margin-bottom:16px"><h3>Dónde vive cada cliente (todos los discos conectados)</h3><div id="clientesDiscos" class="mut">cargando…</div></div>
<div class="box"><h3>Discos</h3><div id="discos" class="mut">cargando…</div></div>
</details>
</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 class="toolbar" style="margin-bottom:12px">
<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="planAvisos"></div>
<div id="planPasos"></div>
</div>
<div class="sub" id="s-nombrar">
<p class="ayuda">La app mira qué hay dentro de cada carpeta y propone qué es. Confirma con un clic o elige otra cosa; se lo aprende para todos los discos. <span class="q">?</span><span class="mas">Solo se ordenan solas las carpetas reconocidas. Las que dejes sin nombrar no se mueven. Una sugerencia «segura» sale cuando casi todo el contenido es de un mismo tipo (por ejemplo, 90 % clips de cámara).</span></p>
<div class="toolbar"><button id="btnAceptarSug" class="p" disabled>Aceptar las sugerencias seguras</button><input type="text" id="filtroVoc" placeholder="buscar carpeta…" style="min-width:200px"><label><input type="checkbox" id="soloSin" checked> solo sin reconocer</label><label><input type="checkbox" id="sinPiezas" checked> ocultar lo de dentro de piezas</label><span class="sep"></span><span id="vocResumen" class="mut"></span></div>
<div class="box"><ul class="nombrar" id="nombrar"><li class="mut">cargando…</li></ul></div>
<details class="mas"><summary>Reglas exactas aprendidas al editar un destino en el plan</summary><div id="reglas" class="mut" style="margin-top:8px">ninguna todavía</div></details>
</div>
<div class="sub" id="s-stock">
<p class="ayuda">Las tomas de Envato de proyectos ya publicados sobran: se vuelven a descargar con la suscripción. <span class="q">?</span><span class="mas">Se detectan por el nombre (…-2026-01-22-22-34-00-utc) o por estar en una carpeta «Elements». Van a <span class="path">_Para borrar/stock</span>; el borrado definitivo lo hace una persona. Se deshace desde Historial.</span></p>
<div class="toolbar">
<label>marcar lo de más de <input type="number" id="stockMeses" value="3" min="0" style="width:56px"> meses</label>
<span class="mut" style="font-size:12px">rápido:</span><button class="lnk" data-m="1">1</button><button class="lnk" data-m="2">2</button><button class="lnk" data-m="3">3</button><button class="lnk" data-m="6">6</button><button class="lnk" data-m="12">12</button>
<label><input type="checkbox" id="stockVideo" checked> video</label>
<label><input type="checkbox" id="stockAudio"> música</label>
<label><input type="checkbox" id="stockImg"> imágenes</label>
<button id="btnStock" class="p">Buscar stock viejo</button>
<span class="sep"></span><span id="stockResumen" class="mut"></span>
<button id="btnStockPapelera" class="bad" disabled>Apartar a _Para borrar/stock</button><span class="por-que" id="btnStockPapeleraPorQue">Busca primero</span>
</div>
<div class="box" id="stockCaja" style="display:none;max-height:420px;overflow:auto"><table id="tablaStock"><thead><tr><th></th><th>Carpeta</th><th>Mes de la pieza</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div>
</div>
<div class="sub" id="s-vacias">
<p class="ayuda">Carpetas sin un solo archivo dentro: lo que queda cuando el material ya se movió. <b>Es lo único que la app borra</b>, y solo porque no hay nada que perder. <span class="q">?</span><span class="mas">Se comprueba en el disco antes de enseñarlas, y otra vez justo antes de borrarlas: si alguien dejó algo dentro mientras tanto, esa se salta y se avisa. <b>Deshacer</b> las vuelve a crear con su nombre. Las que son un enlace o una unión de Windows no se tocan nunca, aunque parezcan vacías. Fíjate en los nombres antes de marcar: una carpeta vacía llamada «03- Exfoliación química» es lo único que queda de esa pieza.</span></p>
<div class="toolbar">
<button id="btnVacias" class="p">Buscar carpetas vacías</button>
<button class="lnk" id="btnVaciasTodas" style="display:none">Marcar todas</button>
<span class="sep"></span><span id="vaciasResumen" class="mut"></span>
<button id="btnVaciasBorrar" class="bad" disabled>Borrar las marcadas</button><span class="por-que" id="btnVaciasBorrarPorQue">Busca primero</span>
</div>
<div class="box" id="vaciasCaja" style="display:none;max-height:460px;overflow:auto"><table id="tablaVacias"><thead><tr><th></th><th>Carpeta</th><th class="num">Subcarpetas vacías dentro</th></tr></thead><tbody></tbody></table></div>
</div>
</div>
</section>
<!-- ============ MAPA ============ -->
<section id="t-mapa">
<div class="cab"><div><h2 class="v">Mapa</h2><div class="sub" id="mapaSub">cómo van a quedar las carpetas y qué va en cada una</div></div></div>
<p class="ayuda">Este árbol sale de las plantillas de verdad, así que es exactamente lo que la app va a hacer al ordenar. <span class="q">?</span><span class="mas">Las carpetas en cursiva cambian según el material (el año, el mes, el nombre de la pieza, el programa del proyecto). Las cifras son el material que tienes hoy en esta carpeta, repartido por lo que es cada archivo. Toca cualquier carpeta del árbol para ver qué guarda y cambiar dónde va.</span></p>
<div class="toolbar">
<label>tipo de trabajo <select id="mapaTipo"></select></label>
<span class="sep"></span>
<button id="btnPintar" title="Le pone a cada carpeta un icono del color de lo que guarda, para verlo en el Explorador">Pintar las carpetas en Windows</button>
<button class="lnk" id="btnDespintar" style="display:none">Quitar los colores</button>
<span id="mapaResumen" class="mut"></span>
</div>
<div class="box plano">
<h3>¿Dónde va a parar este archivo?</h3>
<p class="mut" style="font-size:12.5px;margin:0 0 8px">Escribe el nombre de cualquier archivo, exista o no, y te digo qué es y en qué carpeta lo dejaría. Lo decide el mismo clasificador que usa el escaneo.</p>
<input type="text" id="mapaArchivo" placeholder="A006_12171629_C001.braw" style="width:100%;max-width:460px">
<div class="chips" id="mapaEjemplos"></div>
<div id="mapaRespuesta"></div>
</div>
<div class="row" style="grid-template-columns:1.35fr 1fr">
<div class="box"><h3>Cómo van a quedar las carpetas</h3><div id="mapaArbol" class="arbol mut">cargando…</div></div>
<div class="box"><h3>Qué va en esta carpeta</h3><div id="mapaDetalle" class="det mut">Toca una carpeta del árbol para ver qué guarda y cambiar dónde va.</div></div>
</div>
<div class="box" id="mapaSinSitio" style="display:none"><h3>Sin carpeta asignada</h3><div class="mut" style="font-size:12.5px;margin-bottom:6px">Este material no tiene dónde ir con este tipo de trabajo: se queda donde está. Añádele una carpeta y la app empezará a ordenarlo.</div><div id="mapaSinSitioLista"></div></div>
</section>
<!-- ============ CONVERTIR ============ -->
<section id="t-convertir">
<div class="cab"><h2 class="v">Convertidor</h2><span class="sub">clips de cámara <b id="convCriterio">con más de 3 meses y más de 300 MB</b> · <details class="ajc" id="convAjustes"><summary>cambiar</summary><div class="toolbar">
<label>con más de <input type="number" id="optMeses" value="3" min="0" style="width:60px"> meses</label>
<label>mínimo <input type="number" id="optMin" value="300" min="0" style="width:70px"> MB</label>
<label>calidad <select id="optPerfil"></select></label>
<label>croma <select id="optChroma"><option value="420">4:2:0 10 bits (compatible)</option><option value="422">4:2:2 10 bits (solo ffmpeg, pesa más)</option></select></label>
<label>BRAW en <select id="optColor"><option value="rec709">Rec.709 (se ve bien)</option><option value="log">Log de cámara (plano, más latitud)</option></select></label>
<label><input type="checkbox" id="optExport"> incluir exportados</label>
</div></details></span></div>
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW van por DaVinci Resolve Studio (debe estar abierto) y solo salen a H.265 10 bits; el resto por ffmpeg, con la GPU cuando la hay. Nada se borra.</span></p>
<div class="toolbar" style="margin-bottom:10px"><label>formato de salida <select id="optFormato"></select></label><span id="fmtQue" class="mut" style="font-size:12px"></span><span class="sep"></span><span id="motores" class="mut" style="font-size:12px"><span class="spin"></span> detectando GPU y CPU…</span></div>
<div class="cards c4" id="optCards"></div>
<div class="toolbar">
<button id="btnBuscarClips" class="p">Buscar clips</button>
<button id="btnEncolar" disabled>Convertir</button>
<button id="btnReemplazar" class="ok" disabled>Reemplazar</button><span class="por-que" id="btnReemplazarPorQue"></span>
<span class="sep"></span>
<span id="colaEstado" class="est pendiente">sin actividad</span>
<button class="lnk" id="btnColaToggle" style="display:none">Detener</button>
<button class="lnk" id="btnReintentar" style="display:none">Reintentar los que fallaron</button>
<button class="lnk" id="btnVerificar" style="display:none">Volver a verificar</button>
<button class="lnk" id="btnQuitar" style="display:none">Limpiar terminados</button>
<button class="lnk" id="btnQuitarEspera" style="display:none">Quitar los que esperan</button>
</div>
<div id="anaEstado" class="mut" style="margin:-6px 0 10px"></div>
<div class="box" id="convCaja"><div id="convLista"><span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span></div></div>
</section>
<!-- ============ RESPALDAR ============ -->
<section id="t-respaldar">
<h2 class="v">Respaldar</h2>
<p class="ayuda">Último paso: cuando la carpeta ya está limpia, ordenada y convertida, copia a otro disco una versión <b>compacta</b> del cliente. Puedes ir por lotes: solo lo de más de 3, 6 o 12 meses. <span class="q">?</span><span class="mas">Misma estructura de carpetas, cada una separada por tipo de medio (Video, Audio, Imagenes, Diseño, Proyectos, Documentos). Deja <span class="path">INDICE.csv</span> y <span class="path">_MAPA.md</span> en la raíz del respaldo. Es incremental: lo que ya está igual no se vuelve a copiar. Los originales de cámara sin convertir no se copian salvo que marques «incluir BRAW / RAW».</span></p>
<div id="respAviso"></div>
<div class="toolbar">
<label>destino <input type="text" id="respDestino" placeholder="T:\Archivo Labstream" style="min-width:300px"></label>
<label>qué copiar <select id="respMeses"><option value="0">todo el cliente</option><option value="3">solo lo de más de 3 meses</option><option value="6">solo lo de más de 6 meses</option><option value="12">solo lo de más de 12 meses</option></select></label>
<label><input type="checkbox" id="respTipo" checked> subcarpeta por tipo de medio</label>
<label><input type="checkbox" id="respSinStock" checked> sin stock de Envato</label>
<label><input type="checkbox" id="respRaw"> incluir BRAW / RAW sin convertir</label>
<button id="btnRespCalc" class="p">Calcular</button>
<button id="btnRespCopiar" class="ok" disabled>Copiar</button><span class="por-que" id="btnRespCopiarPorQue">Calcula primero</span>
<button id="btnRespDetener" class="bad" disabled>Detener</button>
<span id="respEstado" class="mut"></span>
</div>
<div class="cards" id="respCards" style="grid-template-columns:repeat(auto-fit,minmax(150px,1fr))"></div>
<div class="bar" style="margin-bottom:14px"><i id="respBarra" style="width:0%"></i></div>
<div class="row">
<div class="box"><h3>Por tipo de medio</h3><div id="respTipos" class="mut">calcula para verlo</div></div>
<div class="box"><h3>Originales de cámara que NO se copian (conviértelos primero)</h3><div id="respRawLista" class="mut">calcula para verlo</div></div>
</div>
<div class="box"><h3>Muestra de cómo queda</h3><div id="respMuestra" class="path mut">calcula para verlo</div></div>
<div class="box" style="margin-top:14px"><h3>Errores de la copia</h3><pre id="respErrores" class="mut">ninguno</pre></div>
</section>
<!-- ============ 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í, 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>
<!-- ============ AJUSTES ============ -->
<section id="t-ajustes">
<div class="cab"><h2 class="v">Clientes</h2><div class="der"><input type="text" id="filtroCli" placeholder="buscar cliente…" style="min-width:200px"><button id="btnAddCliente">+ Añadir cliente</button></div></div>
<p class="ayuda">Un cliente puede tener material en varias carpetas de varios discos; aquí se dice cuáles son y con qué otros nombres aparece. <span class="q">?</span><span class="mas">Las carpetas registradas son las que la app escanea y ordena. Los otros nombres (alias) sirven para reconocer al cliente en carpetas de otros discos. Casi nunca hace falta tocar nada: al soltar una carpeta en la barra lateral se registra sola.</span></p>
<div id="clientes"></div>
<div class="box" style="margin-top:18px"><h3>Actualizaciones</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="actAuto" style="margin-top:3px"><span>Al arrancar, consultar si hay una versión nueva en Labstream OS. <span class="mut">Es lo único que sale a la red; sin esto el programa no habla con nada fuera de este equipo. El botón «Buscar actualizaciones» sigue funcionando a mano.</span></span></label>
</div>
<details class="mas" style="margin-top:18px"><summary>Avanzado: plantillas de estructura por tipo de trabajo</summary>
<div class="box" style="margin-top:10px">
<div class="mut" style="margin-bottom:8px">Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.</div>
<div id="plantillas"></div>
<div class="toolbar" style="margin-top:10px"><button id="btnGuardarPlantillas" class="p">Guardar plantillas</button></div>
</div>
</details>
</section>
</main>
</div>
<div id="tareas" aria-live="polite"><span class="tit">En curso</span></div>
<div id="avisos"></div>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitulo"><div class="box"><h3 id="modalTitulo">…</h3><div id="modalCuerpo"></div><div class="toolbar" style="margin-top:12px;justify-content:flex-end"><button id="modalNo">Cancelar</button><button id="modalSi" class="p">Confirmar</button></div></div></div>
<script>
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const gb = b => { b = b || 0; if (b >= 1e12) return (b/1e12).toFixed(2) + ' TB'; if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB'; if (b >= 1e6) return (b/1e6).toFixed(0) + ' MB'; return (b/1e3).toFixed(0) + ' KB'; };
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fecha = ts => ts ? new Date(ts*1000).toLocaleString('es-CO', {dateStyle:'medium', timeStyle:'short'}) : '';
const fechaCorta = ts => ts ? new Date(ts*1000).toLocaleDateString('es-CO', {day:'numeric', month:'short'}) : '';
const hora = ts => ts ? new Date(ts*1000).toLocaleTimeString('es-CO', {hour:'2-digit', minute:'2-digit'}) : '';
const num = n => (n || 0).toLocaleString('es-CO');
const dur = s => { s = Math.round(s||0); return s >= 3600 ? `${Math.floor(s/3600)}h ${Math.floor(s%3600/60)}m` : `${Math.floor(s/60)}m ${s%60}s`; };
const COLORES = {ANIO:'#4f8cff',GRABACION:'#e0574f',PIEZA:'#7c5cff',CAMPANA:'#ff8c42',EXPORTADOS:'#3ecf8e',PORTADAS:'#f5b84b',APOYOS:'#d96fd9',MARCA:'#39b8c9',PROYECTOS:'#8fa1b8',MUSICA:'#c9b339',SFX:'#c9b339',VOZ:'#c9b339',IA:'#a78bfa',STOCK:'#5aa8e0',CONTENEDOR:'#6b7280',REVISAR:'#ff6b6b',PROXY:'#555c6b',BASURA:'#3a3f4a',APARTADO:'#9aa3ad','':'#4b5563'};
const NOMBRE_CUBETA = {ANIO:'año',GRABACION:'grabación',PIEZA:'pieza',CAMPANA:'campaña',EXPORTADOS:'exportados',PORTADAS:'portadas',APOYOS:'apoyos',MARCA:'marca',PROYECTOS:'proyectos',MUSICA:'música',SFX:'sonidos',VOZ:'voz',IA:'IA',STOCK:'stock',CONTENEDOR:'contenedor',REVISAR:'por revisar',PROXY:'proxies',BASURA:'basura',CELULAR:'celular',FOTOS:'fotos',APARTADO:'apartado por la app'};
const SEGURIDAD = {alta:'seguro', media:'revisar', baja:'dudoso'};
let ESTADO = null, MAPA = null, PLAN = null, CAND = [], VOC = [], STOCK = [], RESP = null, CARPETA = null;
// ---------------- red y errores ----------------
class ApiError extends Error {}
async function api(u, body){
let r;
try { r = await fetch(u, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : undefined); }
catch (e) { throw new ApiError('No hay conexión con OrganiLAB. ¿Se cerró el programa?'); }
let j = null; try { j = await r.json(); } catch (e) { throw new ApiError(`Respuesta rara del programa (${r.status})`); }
// el JSON del error viaja con la excepción: trae la traza del servidor o, en «Desconectar», qué conversiones estorban
if (!r.ok || (j && j.error)) { const e = new ApiError((j && j.error) || `Error ${r.status}`); e.datos = j || {}; throw e; }
return j;
}
// Los avisos de error se apuntan (últimos 40, en este navegador) para poder leerlos después de que el globo
// desaparezca: salen en Historial › «Avisos recientes», con su detalle si lo hay.
const AVISOS_CLAVE = 'organilab.avisos';
function avisosGuardados(){ try { return JSON.parse(localStorage.getItem(AVISOS_CLAVE) || '[]'); } catch (e) { return []; } }
function apuntarAviso(texto, detalle){ try { const l = avisosGuardados(); l.unshift({ts: Date.now() / 1000, texto, detalle: detalle || '', raiz: (typeof raizActual === 'function' && $('#raiz')) ? raizActual() : ''}); localStorage.setItem(AVISOS_CLAVE, JSON.stringify(l.slice(0, 40))); } catch (e) {} }
function aviso(texto, tipo, enlace, detalle){
const d = document.createElement('div'); d.className = 'aviso ' + (tipo || '');
if (tipo === 'bad') { apuntarAviso(texto, detalle); if (!enlace) enlace = {texto: 'Detalle', ir: () => modal('Qué pasó', `<p>${esc(texto)}</p>${detalle ? `<pre>${esc(detalle)}</pre>` : ''}<p class="mut" style="font-size:12px">Queda apuntado en Historial › Avisos recientes.</p>`, null)}; }
d.innerHTML = `<span>${esc(texto)}</span>${enlace ? `<a>${esc(enlace.texto)}</a>` : ''}<span class="x" title="Cerrar">✕</span>`;
if (enlace) d.querySelector('a').onclick = () => { enlace.ir(); d.remove(); };
d.querySelector('.x').onclick = () => d.remove();
$('#avisos').appendChild(d); setTimeout(() => d.remove(), tipo === 'bad' ? 12000 : 7000);
}
async function accion(btn, fn){
if (btn.disabled) return;
// 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 { 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) || ''));
function bloquear(id, motivo){ const b = $('#' + id); if (!b) return; b.disabled = !!motivo; const p = $('#' + id + 'PorQue'); if (p) p.textContent = motivo && motivo !== 'x' ? motivo : ''; }
const seguro = fn => async (...a) => { try { await fn(...a); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); } };
$$('.ayuda .q').forEach(q => q.onclick = () => q.closest('.ayuda').classList.toggle('abierta'));
function raizActual(){ return $('#raiz').value; }
function cfgRaiz(){ return (ESTADO.raices||[]).find(r => r.clave === raizActual()); }
// ---------------- vistas ----------------
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(); }
$$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s));
const cargarVista = seguro(async t => {
t = t || vistaActual();
const r = cfgRaiz();
if (t !== 'carpeta' && t !== 'ajustes' && r) { try { CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raizActual())); pintarIndice(); } catch (e) { console.warn(e); } }
if (t === 'carpeta') await cargarCarpeta();
if (t === 'revisar') { await cargarMapa(); await cargarVoc(); if (PLAN) pintarPlan(); if (STOCK.length && STOCK[0].raiz !== raizActual()) { STOCK = []; $('#stockCaja').style.display = 'none'; } if (VACIAS && VACIAS.raiz !== raizActual()) { VACIAS = null; pintarVacias(); } }
if (t === 'mapa') await cargarMapaEst();
if (t === 'convertir') { await cargarCola(); }
if (t === 'respaldar') { if (!$('#respDestino').value) $('#respDestino').value = (ESTADO.config.respaldo || {}).destino || ''; pintarRespaldo(); }
if (t === 'historial') { await cargarBit(); }
if (t === 'ajustes') pintarAjustes();
});
// ---------------- estado general (un solo sondeo) ----------------
let ULTIMO = null;
async function cargarEstado(){
ESTADO = await api('/api/estado');
const sel = $('#raiz'); const antes = sel.value;
sel.innerHTML = ESTADO.raices.map(r => `<option value="${esc(r.clave)}">${esc(r.cliente)}</option>`).join('');
if (antes && ESTADO.raices.some(r => r.clave === antes)) sel.value = antes;
pintarIndice();
const fmt = $('#optFormato'); if (!fmt.options.length && ESTADO.formatos) { fmt.innerHTML = Object.entries(ESTADO.formatos).map(([k, v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); fmt.value = ESTADO.config.optimizar.formato || 'h265_10bit'; pintarFormato(); cargarMotores(); }
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
const r = cfgRaiz(); const a = r && r.analisis;
if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total} <button class="lnk" id="btnAnaDetener">Detener</button>`;
else if (a && a.error && vistaActual() === 'convertir') $('#anaEstado').innerHTML = `<span class="warn">${esc(a.error)}</span>`;
const bad = $('#btnAnaDetener'); if (bad) bad.onclick = () => accion(bad, async () => { await api('/api/optimizar/analizar/detener', {raiz: raizActual()}); });
const h = ESTADO.herramientas || {}; const faltas = [];
if (h.ffmpeg && !h.ffmpeg.ok) faltas.push('ffmpeg no encontrado: sin él no se puede leer ni convertir (pon ffmpeg.exe y ffprobe.exe en la carpeta ffmpeg\\ junto al programa)');
if (h.resolve && !h.resolve.ok) faltas.push('DaVinci Resolve no está instalado: los BRAW no se podrán convertir');
$('#reloj').innerHTML = (faltas.length ? `<span class="warn" title="${esc(faltas.join('\n'))}">⚠ ${faltas.length} aviso${faltas.length > 1 ? 's' : ''}</span> · ` : '') + `v${esc(ESTADO.version || '')}`;
pintarTareas();
pintarActualizacion();
const ac = ESTADO.actualizacion || {}; $('#actUltima').textContent = ac.comprobado ? `última comprobación ${hora(ac.comprobado)}` : '';
detectarCambios();
}
let BUSCANDO = false;
function detectarCambios(){
const ahora = huella(ESTADO); const antes = ULTIMO; ULTIMO = ahora;
if (!antes) return;
const raiz = raizActual();
const fin = (k, texto, ir) => { if (antes[k] && !ahora[k]) { aviso(texto, 'ok', ir); cargarVista(); } };
fin('esc:' + raiz, 'Escaneo terminado.');
if (antes['ana:' + raiz] && !ahora['ana:' + raiz]) { if (BUSCANDO || vistaActual() === 'convertir') { BUSCANDO = false; irA('convertir'); cargarCandidatos().catch(e => aviso(e.message, 'bad')); } else aviso('Lectura de clips terminada.', 'ok', {texto: 'Ver clips', ir: () => { irA('convertir'); cargarCandidatos(); }}); }
fin('cola', 'Conversión terminada.', {texto: 'Ver', ir: () => irA('convertir')});
if (antes.asis && !ahora.asis && vistaActual() === 'carpeta') pintarAcciones().catch(() => {});
fin('asis', 'Listo: se hizo todo lo seguro de esa carpeta.' + (ESTADO.asistente.resultado && ESTADO.asistente.resultado.errores && ESTADO.asistente.resultado.errores.length ? ` ${ESTADO.asistente.resultado.errores.length} avisos.` : ''), {texto: 'Ver historial', ir: () => irA('historial')});
fin('resp', 'Copia de respaldo terminada.', {texto: 'Ver', ir: () => irA('respaldar')});
if ((antes.cola !== ahora.cola || ahora.cola) && vistaActual() === 'convertir' && !document.querySelector('#convLista input:focus')) cargarCola();
}
function huella(E){
const h = {};
E.raices.forEach(r => { if (r.progreso && r.progreso.estado === 'corriendo') h['esc:' + r.clave] = 1; if (r.analisis && r.analisis.estado === 'corriendo') h['ana:' + r.clave] = 1; });
if (E.cola.activa) h.cola = 1; if (E.asistente.activo) h.asis = 1; if (E.respaldo.estado === 'corriendo') h.resp = 1;
return h;
}
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; VOC = []; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
function limpiarTablas(){
$('#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');
}
$('#btnEscanear').onclick = () => accion($('#btnEscanear'), async () => { await api('/api/escanear', {raiz: raizActual()}); await cargarEstado(); });
// ---------------- barra de tareas ----------------
function tareasDe(E){
const T = []; const porNombre = k => (E.raices.find(r => r.clave === k) || {cliente: k}).cliente;
E.raices.forEach(r => {
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.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', {})}); }
const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct});
return T;
}
// tiempo restante estimado: se recuerda cuándo se vio cada tarea por primera vez y a qué % iba; con eso se extrapola
const ETA = {};
function etaDe(t){
if (t.pct == null) return '';
const k = t.q + '|' + (t.raiz || ''); const ahora = Date.now();
const h = ETA[k] && ETA[k].pct <= t.pct ? ETA[k] : (ETA[k] = {t0: ahora, pct: t.pct});
const avance = t.pct - h.pct, seg = (ahora - h.t0) / 1000;
if (avance < 2 || seg < 8) return '';
const resta = seg * (100 - t.pct) / avance;
return resta < 60 ? '≈ ' + Math.max(5, Math.round(resta / 5) * 5) + ' s' : resta < 3600 ? '≈ ' + Math.round(resta / 60) + ' min' : '≈ ' + (resta / 3600).toFixed(1) + ' h';
}
function pintarTareas(){
const T = tareasDe(ESTADO);
const vivas = new Set(T.map(t => t.q + '|' + (t.raiz || ''))); Object.keys(ETA).forEach(k => { if (!vivas.has(k)) delete ETA[k]; });
const caja = $('#tareas'); caja.classList.toggle('on', T.length > 0);
caja.innerHTML = '<span class="tit">En curso</span>' + T.map((t, i) => `<div class="tarea run"><div class="w"><div class="q">${esc(t.q)}</div><div class="d" title="${esc(t.d)}">${esc(t.d)}</div><div class="b ${t.pct == null ? 'ind' : ''}"><i style="width:${t.pct == null ? 30 : t.pct}%"></i></div></div>${t.pct != null ? `<span class="pct">${t.pct} %<small>${etaDe(t)}</small></span>` : ''}${t.cancelar ? `<button data-i="${i}">Detener</button>` : ''}</div>`).join('');
$$('#tareas button').forEach(b => b.onclick = () => accion(b, async () => { await T[+b.dataset.i].cancelar(); await cargarEstado(); }));
}
// ---------------- barra lateral: carpetas y riel de pasos ----------------
const IR_PASO = ['carpeta', 'revisar', 'revisar', 'convertir', 'respaldar'];
const SUB_PASO = [null, 'nombrar', 'plan', null, null];
function pintarIndice(){
if (!ESTADO) return;
const act = raizActual(); const ocupadas = new Set(tareasDe(ESTADO).map(t => (t.raiz || '').toLowerCase()));
const v = vistaActual(); const aqui = {revisar: [1, 2], convertir: [3], respaldar: [4], carpeta: []}[v] || [];
$('#indice').innerHTML = ESTADO.raices.map(r => {
const run = ocupadas.has(r.clave.toLowerCase());
const e = run ? ['run', 'trabajando…'] : r.escaneo ? ['ok', `escaneada · ${gb(r.escaneo.bytes)}`] : ['', 'sin escanear'];
const disco = (r.ruta.match(/^[A-Za-z]:|^\\\\[^\\]+/) || [''])[0].toUpperCase();
let riel = '';
if (r.clave === act && CARPETA && CARPETA.raiz && CARPETA.raiz.toLowerCase() === r.clave.toLowerCase()) {
const P = pasosDe(CARPETA, r);
riel = `<div class="riel">${P.map((p, i) => `<div class="${p.e === 'hecho' ? 'ok' : p.e === 'curso' || p.e === 'listo' ? 'cur' : p.e} ${aqui.includes(i) ? 'aqui' : ''}" data-i="${i}" title="${esc(p.t)}: ${esc(p.d)}"><span class="pto ${p.e === 'hecho' ? 'ok' : p.e === 'curso' ? 'run' : p.e === 'listo' ? 'cur' : ''}"></span>${esc(p.t)}<small>${esc(p.corto || '')}</small></div>`).join('')}</div>`;
}
return `<div class="cl ${r.clave === act ? 'on' : ''}" data-r="${esc(r.clave)}" title="${esc(r.ruta)}" tabindex="0"><div class="n"><span>${esc(r.cliente)}</span><em>${esc(disco)}</em></div><div class="e"><span class="pto ${e[0]}"></span>${esc(e[1])}<button class="x" data-x="${esc(r.clave)}" title="Quitar de la lista (no borra nada del disco)" aria-label="Quitar de la lista">✕</button></div>${riel}</div>`;
}).join('') || '<div class="mut" style="font-size:12.5px">Ninguna todavía.</div>';
$$('#indice .cl').forEach(el => { const ir = () => { if ($('#raiz').value === el.dataset.r) { irA('carpeta'); return; } $('#raiz').value = el.dataset.r; $('#raiz').onchange(); }; el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } }; });
$$('#indice .x').forEach(el => el.onclick = e => { e.stopPropagation(); desconectar(el.dataset.x); });
$$('#indice .riel div').forEach(el => el.onclick = e => { e.stopPropagation(); const i = +el.dataset.i; if (el.classList.contains('bloq')) return; if (i === 0) { if (!CARPETA.escaneo) $('#btnEscanear').click(); else modal('Volver a escanear', '<p>Se vuelve a leer toda la carpeta (nombres, tamaños y fechas). No mueve nada. El plan calculado se descarta y se calcula de nuevo.</p>', () => $('#btnEscanear').click()); return; } irA(IR_PASO[i]); if (SUB_PASO[i]) subRev(SUB_PASO[i]); });
}
// ---------------- desconectar una carpeta ----------------
function desconectar(clave){
const r = (ESTADO.raices || []).find(x => x.clave === clave); if (!r) return;
const ocupada = tareasDe(ESTADO).some(t => (t.raiz || '').toLowerCase() === clave.toLowerCase());
if (ocupada) { aviso('Esa carpeta tiene una tarea en curso. Espera a que termine.', 'bad'); return; }
modal('Quitar «' + r.cliente + '» de la lista', `<p>La carpeta <span class="path">${esc(r.ruta)}</span> desaparece de la lista de la izquierda. <b>No se borra ni se mueve nada del disco</b>; puedes volver a soltarla cuando quieras.</p><label style="display:flex;gap:8px;align-items:flex-start;margin-top:10px;font-size:13px"><input type="checkbox" id="mDatos" style="margin-top:3px"><span>Olvidar también el inventario escaneado${r.escaneo ? ` (${num(r.escaneo.n)} archivos · ${gb(r.escaneo.bytes)})` : ''}. Si la vuelves a conectar habrá que escanearla de nuevo.</span></label>`, async () => {
const olvidar = $('#mDatos').checked;
const listo = async (n) => { aviso(n ? `Carpeta quitada de la lista; ${n} conversión${n > 1 ? 'es' : ''} descartada${n > 1 ? 's' : ''}.` : 'Carpeta desconectada.', 'ok'); await cargarEstado(); const s = $('#raiz'); if (s.value === clave) { s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); } else { pintarIndice(); cargarVista(); } };
try {
await api('/api/raiz/quitar', {raiz: clave, datos: olvidar});
await listo(0);
} catch (e) {
const c = e.datos && e.datos.cola;
if (!c) { aviso(e.message, 'bad', null, detalleDe(e)); return; }
// Conversiones sin terminar: se enseñan (cuáles, en qué estado) y, si ninguna está convirtiéndose ahora,
// se ofrece descartarlas y desconectar de una vez. Antes solo salía un globo sin decir cuáles.
apuntarAviso(e.message, c.archivos.join('\n'));
modal('No se pudo quitar «' + r.cliente + '»', `<p>${esc(e.message)}.</p><p><b>${c.n}</b> conversión${c.n > 1 ? 'es' : ''} sin terminar${c.por_estado.corriendo ? ` (${c.por_estado.corriendo} convirtiéndose ahora)` : ''}:</p><pre>${esc(c.archivos.join('\n'))}${c.n > c.archivos.length ? `\n… y ${c.n - c.archivos.length} más` : ''}</pre><p class="mut" style="font-size:12.5px">${c.en_curso ? 'Detén la cola en <b>Convertir a 10 bits</b> y vuelve a intentarlo.' : 'Descartarlas las quita de la cola sin tocar ningún archivo: los originales siguen donde están, y si vuelves a conectar la carpeta puedes encolarlas otra vez.'}</p>`,
c.en_curso ? null : async () => { try { await api('/api/raiz/quitar', {raiz: clave, datos: olvidar, descartar_cola: true}); await listo(c.n); } catch (e2) { aviso(e2.message, 'bad', null, detalleDe(e2)); } });
if (!c.en_curso) $('#modalSi').textContent = 'Descartar y quitar';
}
});
}
$('#btnDesconectar').onclick = () => desconectar(raizActual());
// ---------------- carpeta ----------------
const COLOR = {camara_raw:'#6b2f2a', stock_viejo:'#b8923a', stock_reciente:'#d9c58c', camara_video:'#3d5a73', exportados:'#7f8a94', proyectos:'#a9b0b8', basura:'#d3d6da', proxies:'#c4c9ce', audio:'#4f6b5a', celular:'#7a5c8a', fotos:'#8a6f4f', video_otro:'#94a3ad', imagenes:'#b5bec6', documentos:'#cfd5da', otros:'#e1e5e8'};
const NOMBRE_CORTO = {camara_raw:'BRAW', stock_viejo:'stock viejo', stock_reciente:'stock reciente', camara_video:'cámara MP4', exportados:'exportados', proyectos:'proyectos', basura:'basura', proxies:'proxies', audio:'audio', celular:'celular', fotos:'fotos', video_otro:'otros videos', imagenes:'imágenes', documentos:'documentos', otros:'otros'};
async function cargarCarpeta(){
const raiz = raizActual();
$('#inicioVacio').style.display = raiz ? 'none' : 'block';
$('#inicioLleno').style.display = raiz ? '' : 'none';
if (!raiz) return;
const r = cfgRaiz();
CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raiz));
$('#cabTitulo').textContent = r.cliente;
const e = CARPETA.escaneo;
$('#cabSub').innerHTML = `<span class="path">${esc(r.ruta)}</span> · ${esc(ESTADO.tipos[r.tipo] || r.tipo)}${e ? ` · ${num(e.n)} archivos · ${gb(e.bytes)} · escaneada ${fecha(e.ts)}` : ' · sin escanear'}`;
pintarIndice(); pintarSiguiente(pasosDe(CARPETA, r));
pintarAcciones().catch(e => console.warn(e));
const d = CARPETA.diagnostico;
if (!d) { $('#pesoBarra').innerHTML = ''; $('#pesoLinea').innerHTML = ''; $('#inicioDiag').innerHTML = '<span class="mut">Escanea la carpeta para ver qué hay.</span>'; }
else {
$('#pesoBarra').innerHTML = d.grupos.map(g => `<i style="width:${Math.max(g.pct, 0.4)}%;background:${COLOR[g.clave] || '#ccc'}" title="${esc(g.titulo)} · ${gb(g.bytes)} · ${num(g.n)} archivos"></i>`).join('');
$('#pesoLinea').innerHTML = d.grupos.slice(0, 5).map(g => `<b>${gb(g.bytes)}</b> ${esc(NOMBRE_CORTO[g.clave] || g.titulo)}`).join(' · ');
$('#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>${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));
}
// ---------------- lo que se puede hacer en esta carpeta ----------------
// 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(); } },
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(); },
mapa: () => irA('mapa'),
repetidos: () => { irA('revisar'); subRev('hay'); const d = $('#dups'); if (d) d.scrollIntoView({block: 'center', behavior: 'smooth'}); },
convertir: () => { irA('convertir'); $('#btnBuscarClips').click(); },
respaldar: () => irA('respaldar'),
escanear: () => $('#btnEscanear').click(),
};
async function pintarAcciones(){
const raiz = raizActual(); if (!raiz) return;
const d = await api('/api/carpeta/acciones?raiz=' + encodeURIComponent(raiz));
if (raizActual() !== raiz) return; // cambió de carpeta mientras llegaba
ACCIONES = d.acciones || [];
const quedan = ACCIONES.filter(a => a.estado === 'toca').length;
const auto = d.automatico || [], asis = d.asistente;
const corriendo = asis && asis.activo;
const cab = corriendo
? `<div class="cabsec">Trabajando en esta carpeta<span class="sep"></span><span class="mut" style="text-transform:none;letter-spacing:0">${esc(NOMBRE_PASO[asis.paso] || asis.paso || '')} · ${asis.hechas || 0} de ${asis.total || 0}</span></div>`
: `<div class="cabsec">${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día'}${auto.length > 1 ? '<span class="sep"></span><button class="p" id="btnHacerTodo" style="text-transform:none;letter-spacing:0">Hacer las ' + auto.length + ' de una vez</button>' : ''}</div>`;
$('#acciones').innerHTML = cab +
ACCIONES.map((a, i) => `<div class="ac ${a.estado}"><span class="ico">${a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}</span><div class="tx"><b>${esc(a.titulo)}</b><div class="q">${esc(a.que)}</div>${a.detalle ? `<div class="d">${esc(a.detalle)}</div>` : ''}</div><div class="der">${a.cifra ? `<span class="cifra">${esc(a.cifra)}</span>` : ''}${a.boton ? `<button class="${a.estado === 'espera' ? 'lnk' : 'p'}" data-a="${i}">${esc(a.boton)}</button>` : ''}</div></div>`).join('');
$$('#acciones [data-a]').forEach(b => b.onclick = () => { const a = ACCIONES[+b.dataset.a]; (IR_ACCION[a.ir] || (() => {}))(a.paso); });
const bt = $('#btnHacerTodo');
if (bt) bt.onclick = () => modal('Hacer todo lo seguro de una vez',
`<p>La app va a hacer seguidas las <b>${auto.length}</b> cosas de la lista que no necesitan que elijas nada, en orden y sin preguntar.</p>
<p>Solo mueve lo que considera seguro: <b>lo dudoso se queda como está</b> para que lo mires tú. Todo se queda dentro de <span class="path">${esc(raiz)}</span>, nada se borra, y cada cambio se deshace desde el Historial.</p>
<p class="mut" style="font-size:13px">No entran aquí convertir los clips ni copiar el respaldo: esos tienen su propio botón porque hay que elegir cosas.</p>`,
() => accion(bt, async () => {
await api('/api/acciones/hacer', {raiz});
aviso('En marcha. El avance está en la barra de abajo y aquí arriba.', 'ok');
await cargarEstado(); await pintarAcciones();
}));
}
function queSeHizo(lote){
if (!lote) return 'Cambio';
if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por la versión convertida';
if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo';
if (lote.startsWith('dup-')) return 'Juntó material repetido';
if (lote.startsWith('vacias-')) return 'Borró carpetas vacías';
return 'Ordenó carpetas y archivos';
}
function pasosDe(C, r){
const run = r.progreso && r.progreso.estado === 'corriendo';
const esc = !!C.escaneo;
const P = [];
P.push(run ? {e:'curso', t:'Escanear', d:`${num(r.progreso.n)} archivos leídos…`, corto: 'leyendo'}
: esc ? {e:'hecho', t:'Escanear', d:`${fecha(C.escaneo.ts)} · ${num(C.escaneo.n)} archivos`, corto: fechaCorta(C.escaneo.ts)} : {e:'pend', t:'Escanear', d:'Lee nombres y tamaños. Solo lectura.', corto: ''});
const sn = C.sin_nombre_n || 0;
P.push(!esc ? {e:'bloq', t:'Revisar', d:'Después de escanear'} : sn ? {e:'listo', t:'Revisar', d:`${sn} carpeta${sn > 1 ? 's' : ''} por nombrar`, corto: `${sn} por nombrar`} : {e:'hecho', t:'Revisar', d:'Todas las carpetas reconocidas', corto: 'ok'});
const lotes = (C.lotes || []).filter(l => !l.lote.startsWith('stock-') && !l.lote.startsWith('optimizar'));
const asis = C.asistente && C.asistente.activo;
P.push(!esc ? {e:'bloq', t:'Ordenar', d:'Después de escanear'} : asis ? {e:'curso', t:'Ordenar', d: (NOMBRE_PASO[C.asistente.paso] || '') + '…', corto: 'en marcha'}
: C.plan ? {e:'listo', t:'Ordenar', d:`Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`, corto: `${C.plan.marcadas} mov.`}
: lotes.length ? {e:'hecho', t:'Ordenar', d:`Último cambio ${fecha(lotes[0].ts)} · ${lotes[0].movimientos} mov.`, corto: fechaCorta(lotes[0].ts)} : {e:'pend', t:'Ordenar', d:'Calcula qué va a pasar y aplica', corto: ''});
const cv = C.convertir || {}; const tr = cv.trabajos || {}; const n = k => (tr[k] || {}).n || 0;
const activos = n('pendiente') + n('corriendo'), listos = n('listo'), verif = n('verificado'), reemp = n('reemplazado');
const anaRun = cv.analisis && cv.analisis.estado === 'corriendo';
const conError = n('error') + n('cancelado');
// Las conversiones sin terminar o con error se enseñan SIEMPRE, aunque la carpeta ya no tenga material de
// cámara (disco desconectado, material movido): antes el paso decía «nada» mientras la cola bloqueaba Desconectar.
P.push(!esc ? {e:'bloq', t:'Convertir', d:'Después de escanear'}
: anaRun ? {e:'curso', t:'Convertir', d:`Leyendo clips ${cv.analisis.hechos} de ${cv.analisis.total}`, corto: 'leyendo'}
: cv.cola_activa && activos ? {e:'curso', t:'Convertir', d:`${activos} clip${activos > 1 ? 's' : ''} en cola`, corto: `${activos} en cola`}
: activos ? {e:'listo', t:'Convertir', d:`${activos} conversión${activos > 1 ? 'es' : ''} sin terminar, cola parada`, corto: `${activos} parada${activos > 1 ? 's' : ''}`}
: conError ? {e:'listo', t:'Convertir', d:`${conError} conversión${conError > 1 ? 'es' : ''} con error`, corto: `${conError} con error`}
: !cv.camara_bytes ? {e:'hecho', t:'Convertir', d:'No hay material de cámara', corto: 'nada'}
: listos ? {e:'listo', t:'Convertir', d:`${listos} convertido${listos > 1 ? 's' : ''} por verificar`, corto: `${listos} sin verificar`}
: verif ? {e:'listo', t:'Convertir', d:`${verif} verificado${verif > 1 ? 's' : ''} por reemplazar`, corto: `${verif} por reemplazar`}
: reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya convertido${reemp > 1 ? 's' : ''}`, corto: `${reemp} hechos`}
: {e:'pend', t:'Convertir', d:`${gb(cv.camara_bytes)} de cámara${cv.raw_bytes ? ' · ' + gb(cv.raw_bytes) + ' BRAW' : ''}`, corto: gb(cv.camara_bytes)});
const rp = C.respaldo || {};
P.push(!esc ? {e:'bloq', t:'Respaldar', d:'Después de escanear'} : rp.estado === 'corriendo' ? {e:'curso', t:'Respaldar', d:'Copiando…', corto: 'copiando'}
: rp.plan ? {e:'listo', t:'Respaldar', d:`${num(rp.plan.n)} archivos · ${gb(rp.plan.bytes)} listos para copiar`, corto: gb(rp.plan.bytes)}
: {e:'pend', t:'Respaldar', d: rp.destino ? `A ${rp.destino}` : 'Sin destino', corto: ''});
return P;
}
function pintarSiguiente(P){
const C = CARPETA; const s = {t:'', p:'', acts:[]};
const b = (txt, fn, p) => s.acts.push({txt, fn, p});
if (P[0].e === 'curso') { s.t = 'Escaneando la carpeta'; s.p = 'Está leyendo nombres, tamaños y fechas. No toca nada. Cuando termine, aquí aparece el diagnóstico.'; }
else if (P[0].e === 'pend') { s.t = 'Empieza por escanear'; s.p = 'La app lee los nombres, tamaños y fechas de todos los archivos. No mueve nada. En una carpeta grande del NAS tarda unos minutos.'; b('Escanear ahora', () => $('#btnEscanear').click(), true); }
else if (P[2].e === 'curso') { s.t = 'El asistente está trabajando'; s.p = 'Puedes seguir el avance en la barra de abajo.'; }
else if (P[1].e === 'listo' && !C.plan && P[2].e !== 'hecho') { const n = C.sin_nombre_n; s.t = `Antes de ordenar: nombra ${n} carpeta${n > 1 ? 's' : ''}`; s.p = `La app no sabe qué son ${C.sin_nombre.slice(0, 4).map(x => '«' + x.ejemplo + '»').join(', ')}${n > 4 ? ' y otras' : ''}. Si le dices qué es cada una, las ordena bien y se lo aprende para todos los discos. Si prefieres, sáltalo: quedarán como «dudoso» y no se moverán.`; b('Nombrar las carpetas', () => { irA('revisar'); subRev('nombrar'); }, true); b('Saltar y calcular', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }); }
else if (C.plan) { s.t = `Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`; s.p = `Todo se mueve dentro de la misma carpeta y nada se borra. ${C.plan.baja ? C.plan.baja + ' son dudosos y están sin marcar. ' : ''}Revisa la lista y aplica; se puede deshacer desde Historial.`; b('Revisar y aplicar', () => { irA('revisar'); subRev('plan'); }, true); }
else if (P[2].e === 'pend') { s.t = 'Siguiente: ordenar la carpeta'; s.p = 'Calcula qué va a pasar (basura y proxies a _Para borrar, grabaciones por mes y cámara, piezas numeradas) y aplícalo con un clic. O deja que el asistente te pregunte paso a paso.'; b('Calcular qué va a pasar', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }, true); }
else if (P[3].e === 'listo') { const d = P[3].d; s.t = 'Siguiente: ' + (d.includes('verificar') ? 'verificar los clips convertidos' : d.includes('reemplazar') ? 'reemplazar los originales' : 'retomar la conversión'); s.p = d.includes('verificar') ? 'Verificar comprueba códec, 10 bits y duración. Después puedes reemplazar los originales (quedan en _Para borrar/originales).' : d.includes('reemplazar') ? 'Los originales se apartan a _Para borrar/originales y el MP4 10 bits queda en su lugar. Nada se borra.' : 'Hay clips en cola con la conversión parada.'; b('Ir a Convertir', () => irA('convertir'), true); }
else if (P[3].e === 'curso') { s.t = 'Convirtiendo clips'; s.p = 'Sigue en segundo plano. El avance está en la barra de abajo; cuando termine, toca reemplazar.'; b('Ver la cola', () => irA('convertir')); }
else if (P[3].e === 'pend') { const cv = C.convertir; s.t = 'Siguiente: aligerar el material de cámara'; s.p = `Hay ${gb(cv.camara_bytes)} de clips de cámara. Los de proyectos ya entregados pueden pasar a MP4 10 bits, unas 10 veces más ligeros. ${cv.raw_bytes ? 'Los BRAW necesitan DaVinci Resolve Studio abierto. ' : ''}El original no se toca hasta que reemplaces.`; b('Buscar clips a convertir', () => { irA('convertir'); $('#btnBuscarClips').click(); }, true); b('Saltar a respaldar', () => irA('respaldar')); }
else if (P[4].e === 'listo') { s.t = 'Siguiente: copiar el respaldo'; s.p = P[4].d + '. Es incremental: lo que ya esté igual se salta.'; b('Ir a Respaldar', () => irA('respaldar'), true); }
else if (P[4].e === 'curso') { s.t = 'Copiando el respaldo'; s.p = 'Sigue en segundo plano. Puedes detenerlo desde la barra de abajo y retomarlo luego.'; }
else { s.t = 'Siguiente: respaldo compacto en otro disco'; s.p = 'Copia organizada por carpeta y tipo, con los clips ya convertidos en vez de los originales y un índice. ' + (C.respaldo.raw_pendientes ? `${C.respaldo.raw_pendientes} originales de cámara sin convertir no se copiarían.` : ''); b('Calcular el respaldo', () => irA('respaldar'), true); }
$('#siguiente').innerHTML = `<h3>${esc(s.t)}</h3><p>${esc(s.p)}</p><div class="acts">${s.acts.map((a, i) => `<button data-i="${i}" class="${a.p ? 'p' : ''}">${esc(a.txt)}</button>`).join('')}${s.acts.length ? '' : '<span class="nada">Nada que hacer ahora mismo.</span>'}</div>`;
$$('#siguiente button').forEach(bt => bt.onclick = () => s.acts[+bt.dataset.i].fn());
}
async function deshacer(lote){
modal('Deshacer: ' + queSeHizo(lote), '<p>Se devuelve cada archivo a su sitio original, en orden inverso. Si alguien movió algo después, ese archivo se salta y se avisa.</p>', seguro(async () => {
const r = await api('/api/deshacer', {lote});
aviso(`${r.deshechos} movimientos devueltos.` + (r.errores.length ? ` ${r.errores.length} no se pudieron.` : ''), r.errores.length ? 'bad' : 'ok');
if (r.errores.length) modal('No se pudo deshacer todo', `<pre>${esc(r.errores.join('\n'))}</pre>`, null);
PLAN = null; MAPA = null; await cargarEstado(); cargarVista();
}));
}
// ---------------- revisar: qué hay ----------------
async function cargarMapa(){
if (!raizActual()) { $('#mapaVacio').style.display = 'block'; $('#revisarLleno').style.display = 'none'; return; }
MAPA = await api('/api/mapa?raiz=' + encodeURIComponent(raizActual()));
$('#mapaVacio').style.display = MAPA.escaneado ? 'none' : 'block';
$('#revisarLleno').style.display = MAPA.escaneado ? 'block' : 'none';
cargarDiscos().catch(() => {});
if (!MAPA.escaneado) return;
const m = MAPA;
const pct = (b) => m.total.bytes ? Math.round(b/m.total.bytes*100) : 0;
$('#cards').innerHTML = [
[num(m.total.n), 'archivos'], [gb(m.total.bytes), 'en total'],
[gb(m.raw.bytes), `BRAW · ${pct(m.raw.bytes)}% del peso`], [gb(m.grandes.bytes), `videos > 1 GB (${m.grandes.n})`],
[gb(m.duplicados.bytes), `repetidos (${m.duplicados.grupos} grupos)`], [gb(m.proxies.bytes), `proxies (${m.proxies.n})`],
[num(m.basura.n), `archivos basura · ${gb(m.basura.bytes)}`], [m.sueltos_raiz.n, `sueltos en la raíz · ${gb(m.sueltos_raiz.bytes)}`],
].map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join('');
treemap($('#treemap'), m.arbol.map(h => ({nombre:h.nombre, bytes:h.bytes, n:h.n, color: COLORES[h.cubeta] || COLORES[''], hijos:h.hijos, cubeta:h.cubeta})));
const usados = [...new Set(m.arbol.map(h => h.cubeta || ''))];
$('#legend').innerHTML = usados.map(c => `<span><i style="background:${COLORES[c]||COLORES['']}"></i>${NOMBRE_CUBETA[c] || c || 'no reconocida'}</span>`).join('');
const al = [];
if (m.basura.n) al.push(`<li><b>${num(m.basura.n)} archivos basura</b> (${gb(m.basura.bytes)}): .DS_Store, Thumbs.db, .tmp, autoguardados de Adobe. Al ordenar van a _Para borrar.</li>`);
if (m.proxies.n) al.push(`<li><b>${gb(m.proxies.bytes)} en proxies</b> (${m.proxies.n}). Se regeneran desde Resolve/Premiere: sobran en el disco.</li>`);
if (m.duplicados.grupos) al.push(`<li><b>${gb(m.duplicados.bytes)} repetidos</b> en ${m.duplicados.grupos} grupos (${m.duplicados.identicos} idénticos, ${m.duplicados.versiones} en dos formatos). Se juntan en subcarpetas Duplicados junto al que se queda; nada se borra.</li>`);
if (m.raw.bytes) al.push(`<li><b>${gb(m.raw.bytes)} en BRAW</b> (${m.raw.n} clips). A H.265 10 bits pesarían unas 15 veces menos: paso <b>Convertir</b>.</li>`);
if (m.sin_fecha.n) al.push(`<li>${m.sin_fecha.n} clips de cámara sin fecha en el nombre: para ubicarlos por mes se usa la fecha de modificación.</li>`);
if (m.sueltos_raiz.n) al.push(`<li>${m.sueltos_raiz.n} archivos sueltos en la raíz (${gb(m.sueltos_raiz.bytes)}).</li>`);
const noRec = m.arbol.filter(h => !h.cubeta);
if (noRec.length) al.push(`<li>${noRec.length} carpetas de primer nivel sin reconocer: ${noRec.slice(0,8).map(h => `<span class="chip">${esc(h.nombre)}</span>`).join('')}. Dile qué son en <b>Por nombrar</b>.</li>`);
$('#alertas').innerHTML = al.length ? `<ul style="margin:0;padding-left:18px">${al.join('')}</ul>` : '<span class="ok">Sin avisos.</span>';
const barras = (rows, label) => { const max = Math.max(...rows.map(r => r.bytes), 1); return `<table>${rows.map(r => `<tr><td>${esc(label(r))}</td><td style="width:45%"><div class="bar"><i style="width:${Math.round(r.bytes/max*100)}%"></i></div></td><td class="num">${num(r.n)}</td><td class="num">${gb(r.bytes)}</td></tr>`).join('')}</table>`; };
$('#fuentes').innerHTML = barras(m.por_fuente, r => r.fuente);
$('#meses').innerHTML = barras(m.por_mes.slice(-24), r => r.mes);
$('#exts').innerHTML = barras(m.por_ext, r => r.ext || '(sin extensión)');
$('#dups').innerHTML = m.duplicados.ejemplos.length ? m.duplicados.ejemplos.map(d => `<div style="margin-bottom:8px"><b>${esc(d.nombre)}</b> <span class="mut">${d.tipo === 'version' ? 'dos formatos' : 'idénticos'} · ${d.copias} copias · ${gb(d.bytes)} de más</span><ul class="ejemplos" style="margin:2px 0 0 16px;padding:0"><li>se queda: ${esc(d.queda)}</li>${d.rutas.map(r => `<li>→ Duplicados: ${esc(r)}</li>`).join('')}</ul></div>`).join('') : '<span class="mut">ninguno relevante</span>';
}
function treemap(el, items){
el.innerHTML = ''; const W = el.clientWidth || 600, H = el.clientHeight || 300;
const total = items.reduce((s,i) => s + i.bytes, 0) || 1;
const nodos = items.filter(i => i.bytes > 0).sort((a,b) => b.bytes - a.bytes).map(i => ({...i, area: i.bytes/total*W*H}));
let x = 0, y = 0, w = W, h = H, fila = [], resto = nodos.slice();
const peor = (fila, lado) => { const s = fila.reduce((a,b)=>a+b.area,0); const mx = Math.max(...fila.map(f=>f.area)), mn = Math.min(...fila.map(f=>f.area)); return Math.max(lado*lado*mx/(s*s), s*s/(lado*lado*mn)); };
const pinta = (fila, lado, horizontal) => { const s = fila.reduce((a,b)=>a+b.area,0); const grosor = s/lado; let off = 0; fila.forEach(f => { const largo = f.area/grosor; const d = document.createElement('div'); const rx = horizontal ? x+off : x, ry = horizontal ? y : y+off, rw = horizontal ? largo : grosor, rh = horizontal ? grosor : largo; d.style.left = rx+'px'; d.style.top = ry+'px'; d.style.width = rw+'px'; d.style.height = rh+'px'; d.style.background = f.color; d.title = `${f.nombre}\n${gb(f.bytes)} · ${f.n} archivos${f.cubeta ? '\n' + (NOMBRE_CUBETA[f.cubeta] || f.cubeta) : ''}` + (f.hijos && f.hijos.length ? '\n\n' + f.hijos.slice(0,12).map(h => ` ${h.nombre}: ${gb(h.bytes)}`).join('\n') : ''); d.innerHTML = rw > 40 && rh > 18 ? `<b>${esc(f.nombre)}</b>${rh > 34 ? `<span>${gb(f.bytes)}</span>` : ''}` : ''; el.appendChild(d); off += largo; }); if (horizontal) { y += grosor; h -= grosor; } else { x += grosor; w -= grosor; } };
while (resto.length) { const horizontal = w >= h; const lado = horizontal ? w : h; const n = resto.shift(); if (!fila.length || peor(fila.concat(n), lado) <= peor(fila, lado)) fila.push(n); else { pinta(fila, lado, !horizontal); fila = [n]; } }
if (fila.length) pinta(fila, w >= h ? w : h, !(w >= h));
}
async function cargarDiscos(){
const d = await api('/api/discos');
$('#clientesDiscos').innerHTML = `<table><thead><tr><th>Cliente</th><th>Aparece en</th><th class="num">Sitios</th></tr></thead><tbody>${Object.entries(d.clientes).sort((a,b) => b[1].length - a[1].length).map(([c, rutas]) => `<tr><td><b>${esc(c)}</b></td><td>${rutas.map(r => `<span class="chip">${esc(r)}</span>`).join('')}</td><td class="num ${rutas.length > 2 ? 'warn' : ''}">${rutas.length}</td></tr>`).join('')}</tbody></table>`;
$('#discos').innerHTML = `<table><thead><tr><th>Unidad</th><th>Nombre</th><th>Nivel</th><th style="width:22%">Uso</th><th class="num">Libre</th><th>Carpetas de primer nivel</th></tr></thead><tbody>${d.discos.map(x => `<tr><td><b>${x.letra}</b><div class="cell-small">${esc(x.unc)}</div></td><td>${esc(x.nombre)}</td><td><span class="tag">${esc(x.nivel)}</span></td><td><div class="bar"><i style="width:${Math.round((x.total-x.libre)/x.total*100)}%;background:${(x.total-x.libre)/x.total > .9 ? 'var(--bad)' : 'var(--acc)'}"></i></div><div class="cell-small">${gb(x.total-x.libre)} de ${gb(x.total)}</div></td><td class="num">${gb(x.libre)}</td><td>${x.carpetas.slice(0,40).map(c => `<span class="chip" style="${c.cliente ? 'border-color:var(--acc)' : ''}" title="${esc(c.cliente||'')}">${esc(c.nombre)}</span>`).join('')}${x.carpetas.length > 40 ? ` <span class="mut">+${x.carpetas.length-40}</span>` : ''}</td></tr>`).join('')}</tbody></table>`;
}
// ---------------- revisar: por nombrar ----------------
async function cargarVoc(){
if (!raizActual() || !(MAPA && MAPA.escaneado)) { $('#nombrar').innerHTML = '<li class="mut">Escanea primero.</li>'; return; }
const d = await api('/api/vocabulario?raiz=' + encodeURIComponent(raizActual()));
VOC = d.tokens; pintarNombrar(); pintarReglas(d.diccionario.reglas);
}
const opcionesCubeta = c => `<option value="">— no sé / dejar como está —</option>` + Object.entries(ESTADO.cubetas).map(([k,v]) => `<option value="${k}" ${k===c?'selected':''}>${esc(NOMBRE_CUBETA[k] || k)} · ${esc(v)}</option>`).join('');
function pintarNombrar(){
const f = ($('#filtroVoc').value || '').toLowerCase(); const solo = $('#soloSin').checked; const sinP = $('#sinPiezas').checked;
const rows = VOC.filter(t => (!f || t.token.includes(f) || (t.rutas || []).join(' ').toLowerCase().includes(f)) && (!solo || !t.cubeta) && (!sinP || !t.dentro_pieza)).sort((a, b) => (!!a.cubeta - !!b.cubeta) || ((b.sug_conf === 'alta') - (a.sug_conf === 'alta')) || b.bytes - a.bytes);
const sin = VOC.filter(t => !t.cubeta && !t.dentro_pieza).length;
const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta');
$('#vocResumen').textContent = `${rows.length} carpetas · ${sin} sin reconocer${seguras.length ? ` · ${seguras.length} con sugerencia segura` : ''}`;
const bd = $('#badgeNombrar'); bd.textContent = sin; bd.classList.toggle('cero', !sin);
bloquear('btnAceptarSug', seguras.length ? '' : 'x'); $('#btnAceptarSug').textContent = seguras.length ? `Aceptar ${seguras.length} sugerencia${seguras.length > 1 ? 's' : ''} segura${seguras.length > 1 ? 's' : ''}` : 'Aceptar las sugerencias seguras';
$('#nombrar').innerHTML = rows.slice(0, 300).map(t => `<li><span><b title="${esc((t.rutas || []).join('\n'))}">${esc(t.ejemplo)}${t.aprendido ? ' <span class="tag alta">aprendido</span>' : ''}${t.n > 1 ? ` <span class="mut" style="font-weight:500">· ${t.n} carpetas con este nombre</span>` : ''}</b><span class="r">${esc((t.rutas || []).slice(0, 2).join(' · '))}</span>${t.contenido ? `<span class="r">Dentro: ${esc(t.contenido)}</span>` : ''}${!t.cubeta && t.sugerencia ? `<span class="sg">Parece <b>${esc(NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</b> (${SEG_TXT[t.sug_conf] || t.sug_conf}): ${esc(t.por_que)}</span>` : !t.cubeta ? `<span class="sg" style="color:var(--mut)">Sin pista: elige qué es, o déjala y no se moverá.</span>` : ''}</span><span class="acts">${!t.cubeta && t.sugerencia ? `<button class="p" data-si="${esc(t.token)}" data-cub="${esc(t.sugerencia)}">Sí, es ${esc(NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</button>` : ''}<select data-tok="${esc(t.token)}" aria-label="qué es">${opcionesCubeta(t.cubeta)}</select></span><small>${gb(t.bytes)}</small></li>`).join('') + (rows.length > 300 ? `<li class="mut">… y ${rows.length - 300} más: usa el buscador</li>` : '') || (sin ? '<li class="mut">Nada con ese filtro.</li>' : '<li class="ok">Todas las carpetas están reconocidas.</li>');
$$('#nombrar select').forEach(s => s.onchange = () => nombrarToken(s.dataset.tok, s.value));
$$('#nombrar [data-si]').forEach(b => b.onclick = () => nombrarToken(b.dataset.si, b.dataset.cub));
}
$('#btnAceptarSug').onclick = () => accion($('#btnAceptarSug'), async () => {
const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta');
for (const t of seguras) { await api('/api/diccionario', {token: t.token, cubeta: t.sugerencia}); t.cubeta = t.sugerencia; t.aprendido = true; }
PLAN = null; estadoPlan(); if (CARPETA) { CARPETA.sin_nombre_n = VOC.filter(x => !x.cubeta && !x.dentro_pieza).length; pintarIndice(); }
pintarNombrar(); aviso(`${seguras.length} carpetas nombradas. Calcula el plan para verlas ordenadas.`, 'ok');
});
const nombrarToken = seguro(async (tok, cub) => {
await api('/api/diccionario', {token: tok, cubeta: cub});
const t = VOC.find(x => x.token === tok); if (t) { t.cubeta = cub; t.aprendido = !!cub; t.motivo = 'lo dijiste tú'; }
PLAN = null; estadoPlan(); if (CARPETA) { CARPETA.sin_nombre_n = VOC.filter(x => !x.cubeta && !x.dentro_pieza).length; pintarIndice(); }
pintarNombrar();
if (cub) aviso(`«${t ? t.ejemplo : tok}» ahora es ${NOMBRE_CUBETA[cub] || cub}. Calcula el plan de nuevo para verlo.`, 'ok');
});
$('#filtroVoc').oninput = pintarNombrar; $('#soloSin').onchange = pintarNombrar; $('#sinPiezas').onchange = pintarNombrar;
function pintarReglas(reglas){
$('#reglas').innerHTML = reglas.length ? `<table>${reglas.map((r,i) => `<tr><td class="mut">${esc(r.cliente||'todos')}</td><td class="path">${esc(r.origen)}</td><td class="arrow">→</td><td class="path">${esc(r.destino||'(ignorar)')}</td><td><button class="bad" data-i="${i}">quitar</button></td></tr>`).join('')}</table>` : 'ninguna todavía';
$$('#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) ----------------
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.', 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.'},
];
const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'};
let PLAN_ABIERTOS = new Set(), ACCIONES = [];
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';
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()));
pintarPlan();
}
$('#btnPlan').onclick = () => 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)');
}
function fraseDe(r){
const nom = x => `«${esc(x.split(/[\\/]/).pop())}»`;
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 pintarPlan(){
if (!PLAN) { estadoPlan(); return; }
const f = ($('#filtroPlan').value || '').toLowerCase();
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(); })); });
estadoPlan();
}
$('#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> 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: '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(() => {});
}));
$('#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); }
$('#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); };
// ---------------- revisar: stock viejo ----------------
$$('#s-stock button[data-m]').forEach(b => b.onclick = () => { $('#stockMeses').value = b.dataset.m; if (STOCK.length) { STOCK.forEach(g => g.sel = g.meses >= +b.dataset.m); pintarStock(); } });
$('#stockMeses').onchange = () => { if (STOCK.length) { STOCK.forEach(g => g.sel = g.meses >= (+$('#stockMeses').value || 0)); pintarStock(); } };
const TRAMOS = [[12, 'Más de 12 meses'], [6, 'Entre 6 y 12 meses'], [3, 'Entre 3 y 6 meses'], [1, 'Entre 1 y 3 meses'], [0, 'Menos de 1 mes']];
$('#btnStock').onclick = () => accion($('#btnStock'), async () => {
const tipos = [$('#stockVideo').checked && 'Video', $('#stockAudio').checked && 'Audio', $('#stockImg').checked && 'Imagenes'].filter(Boolean).join(',');
const d = await api(`/api/stock?raiz=${encodeURIComponent(raizActual())}&meses=0&tipos=${tipos}`);
const hoy = new Date(); const ahoraM = hoy.getFullYear() * 12 + hoy.getMonth();
const min = +$('#stockMeses').value || 0;
STOCK = d.grupos.map(g => { const m = /^(\d{4})-(\d{2})/.exec(g.mes || ''); const meses = m ? ahoraM - ((+m[1]) * 12 + (+m[2]) - 1) : 0; return {...g, meses, sel: meses >= min, raiz: raizActual()}; });
$('#stockResumen').textContent = `${d.n} archivos de stock · ${gb(d.bytes)} en total`;
$('#stockCaja').style.display = ''; pintarStock();
});
function pintarStock(){
const tramoDe = g => TRAMOS.find(t => g.meses >= t[0]) || TRAMOS[TRAMOS.length - 1];
let html = '';
TRAMOS.forEach(t => {
const gs = STOCK.map((g, i) => ({g, i})).filter(x => tramoDe(x.g) === t);
if (!gs.length) return;
const todos = gs.every(x => x.g.sel);
html += `<tr class="stk-grp"><td><input type="checkbox" data-t="${t[0]}" ${todos ? 'checked' : ''} aria-label="marcar tramo"></td><td colspan="2">${t[1]}</td><td class="num">${num(gs.reduce((a, x) => a + x.g.n, 0))}</td><td class="num">${gb(gs.reduce((a, x) => a + x.g.bytes, 0))}</td><td class="mut" style="font-weight:500">${gs.length} carpeta${gs.length > 1 ? 's' : ''}</td></tr>`;
html += gs.map(({g, i}) => `<tr><td><input type="checkbox" data-i="${i}" ${g.sel ? 'checked' : ''} aria-label="marcar"></td><td class="path">${esc(g.carpeta)}</td><td>${esc(g.mes)}</td><td class="num">${g.n}</td><td class="num">${gb(g.bytes)}</td><td class="cell-small">${g.ejemplos.map(esc).join('<br>')}</td></tr>`).join('');
});
const caja = $('#stockCaja'), y = caja ? caja.scrollTop : 0;
$('#tablaStock tbody').innerHTML = html || '<tr><td colspan="6" class="mut">No hay stock de Envato en esta carpeta.</td></tr>';
if (caja) caja.scrollTop = y;
$$('#tablaStock input[data-i]').forEach(i => i.onchange = () => { STOCK[+i.dataset.i].sel = i.checked; pintarStock(); });
$$('#tablaStock input[data-t]').forEach(i => i.onchange = () => { const t = TRAMOS.find(x => x[0] === +i.dataset.t); STOCK.forEach(g => { if (tramoDe(g) === t) g.sel = i.checked; }); pintarStock(); });
resumenStock();
}
function resumenStock(){ const s = STOCK.filter(g => g.sel); bloquear('btnStockPapelera', STOCK.length ? (s.length ? '' : 'Marca alguna carpeta') : 'Busca primero'); $('#btnStockPapelera').textContent = s.length ? `Apartar ${s.length} carpeta${s.length > 1 ? 's' : ''} (${gb(s.reduce((a, g) => a + g.bytes, 0))})` : 'Apartar a _Para borrar/stock'; }
$('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g.raiz === raizActual()); const rutas = s.flatMap(g => g.rutas); if (!rutas.length) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad'); modal('Apartar stock de Envato', `<p><b>${rutas.length}</b> archivos (${gb(s.reduce((a,g)=>a+g.bytes,0))}) se mueven a <span class="path">_Para borrar/stock</span> dentro de la carpeta del cliente. Nada se borra; se deshace desde Historial.</p>`, () => accion($('#btnStockPapelera'), async () => { const r = await api('/api/stock/papelera', {raiz: raizActual(), rutas}); aviso(`${r.movimientos} archivos de stock apartados.`, 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); STOCK = []; pintarStock(); $('#stockCaja').style.display = 'none'; MAPA = null; await cargarEstado(); })); $('#modalSi').textContent = 'Apartar'; };
// ---------------- convertir ----------------
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses y más de ${+$('#optMin').value || 0} MB`; }
['optMeses','optMin'].forEach(id => $('#' + id).oninput = pintarCriterio);
let COLA_TS = [];
$('#btnBuscarClips').onclick = () => accion($('#btnBuscarClips'), async () => {
const cv = (CARPETA && CARPETA.convertir) || {};
if (cv.sin_analizar > 0 || (cv.analisis && cv.analisis.estado === 'error')) {
const r = await api('/api/optimizar/analizar', {raiz: raizActual(), min_mb: +$('#optMin').value || 0});
if (!r.lanzado) throw new ApiError('Ya se están leyendo los clips de esta carpeta');
BUSCANDO = true; $('#anaEstado').innerHTML = '<span class="spin"></span> leyendo los clips (códec, duración, resolución)…';
await cargarEstado();
} else await cargarCandidatos();
});
let MOTORES = null;
function pintarFormato(){ const f = (ESTADO.formatos || {})[$('#optFormato').value]; $('#fmtQue').textContent = f ? f.que : ''; }
$('#optFormato').onchange = () => { pintarFormato(); if (CAND.length) cargarCandidatos().catch(e => aviso(e.message, 'bad')); };
async function cargarMotores(){
try { MOTORES = await api('/api/optimizar/motores'); } catch (e) { $('#motores').textContent = 'no se pudo detectar GPU/CPU'; return; }
const g = MOTORES.gpu, c = MOTORES.cpu;
const gpuT = g.hevc || g.h264 ? `GPU ${esc(g.nombre || 'NVIDIA')}: ${[g.hevc && 'H.265', g.h264 && 'H.264'].filter(Boolean).join(' y ')} acelerados` : 'Sin GPU NVIDIA: todo por CPU (más lento)';
const cpuT = `CPU: ${[c.prores_ks && 'ProRes', c.libx265 && 'H.265', c.libx264 && 'H.264'].filter(Boolean).join(', ') || 'nada'}`;
$('#motores').innerHTML = MOTORES.ffmpeg ? `${gpuT} · ${cpuT}` : '<span class="bad">ffmpeg no encontrado: no se puede convertir</span>';
const ch = $('#optChroma'); const op422 = ch && ch.querySelector('option[value="422"]');
if (op422) { const hay = MOTORES.chroma422 !== false; op422.disabled = !hay; op422.textContent = '4:2:2 10 bits' + (hay ? (MOTORES.gpu.chroma422 ? ' (pesa más)' : ' (por CPU, más lento)') : ' (este equipo no puede)'); if (!hay && ch.value === '422') ch.value = '420'; }
$$('#optFormato option').forEach(o => { const m = MOTORES.formatos[o.value]; o.disabled = !m; o.textContent = (ESTADO.formatos[o.value] || {}).nombre + (m === 'gpu' ? ' · GPU' : m === 'cpu' ? ' · CPU' : ' · no disponible'); });
}
async function cargarCandidatos(){
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}`;
const d = await api('/api/optimizar/candidatos?' + q); CAND = d.candidatos.map(c => ({...c, sel: !c.excluido && !c.en_cola}));
$('#anaEstado').textContent = '';
if (!CAND.length) aviso('No se encontraron clips con esos criterios. Prueba a cambiar los meses o el tamaño mínimo.', '');
await cargarCola();
}
function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND.filter(c => !c.excluido && !c.en_cola); bloquear('btnEncolar', validos.length ? (s.length ? '' : 'x') : 'x'); $('#btnEncolar').textContent = s.length ? `Convertir ${s.length} marcado${s.length > 1 ? 's' : ''}` : 'Convertir'; }
$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola, su salida ya existe (mismo clip en MP4 y MOV) o son BRAW con un formato que no sea H.265 10 bits.`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; };
const EST_TXT = {pendiente:'en espera', corriendo:'convirtiendo', listo:'convertido', verificado:'verificado', reemplazado:'reemplazado', error:'falló', cancelado:'cancelado'};
async function cargarCola(){
const d = await api('/api/optimizar/cola');
const raiz = raizActual();
const ts = d.trabajos.filter(t => !raiz || (t.raiz || '').toLowerCase() === raiz.toLowerCase());
const orden = {corriendo:0, pendiente:1, listo:2, verificado:2, error:3, reemplazado:4, cancelado:5};
ts.sort((x, y) => (orden[x.estado] ?? 9) - (orden[y.estado] ?? 9) || y.id - x.id);
COLA_TS = ts;
const n = k => ts.filter(t => t.estado === k).length;
const pend = n('pendiente'), listos = n('listo'), verif = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores')).length, err = n('error') + n('cancelado');
const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}` : 'sin actividad';
const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Reanudar';
$('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none';
$('#btnQuitarEspera').style.display = (!d.cola.activa && pend) ? '' : 'none';
bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar';
const validos = CAND.filter(c => !c.excluido), ahora = validos.reduce((s,c)=>s+c.bytes,0), despues = validos.reduce((s,c)=>s+c.estimado,0);
const hechosB = ts.filter(t => t.bytes_out).reduce((s,t) => s + (t.bytes_in - t.bytes_out), 0);
$('#optCards').innerHTML = (CAND.length || ts.length) ? [[validos.length, 'clips convertibles'], [gb(ahora), 'pesan hoy'], [gb(despues), 'pesarían (estimado)'], [hechosB ? (hechosB > 0 ? '−' : '+') + gb(Math.abs(hechosB)) : `${verif}`, hechosB > 0 ? `ya liberados · ${verif} verificados` : hechosB < 0 ? `de más (formato de edición) · ${verif} verificados` : 'verificados']].map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join('') : '';
resumenCand();
let html = '';
if (CAND.length) {
html += `<div class="cabsec"><label style="display:flex;gap:6px;align-items:center;text-transform:none;letter-spacing:0;font-weight:500"><input type="checkbox" id="candTodos" ${validos.length && CAND.filter(c => c.sel).length === validos.filter(c => !c.en_cola).length ? 'checked' : ''}> marcar todos los válidos</label><span class="sep"></span>${CAND.length} candidatos</div>`;
html += CAND.slice(0, 800).map((c, i) => `<div class="cfila ${c.excluido ? 'exc' : ''}"><input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar"><span class="est candidato">${c.en_cola ? 'en cola' : c.excluido ? 'no' : 'candidato'}</span><span class="nom" title="${esc(c.codec)} ${esc(c.perfil)} ${esc(c.pix_fmt)} · ${esc(c.res)} · ${esc(c.fps)} fps · ${c.kbps} kb/s → ≤ ${c.kbps_objetivo} kb/s"><span class="clip">${esc(c.rel)}</span><span class="sub2">${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${c.motor === 'resolve' ? 'Resolve' : 'ffmpeg'}${c.excluido || c.en_cola ? ' · ' + esc(c.excluido || c.en_cola) : ''}</span></span><span class="tam">${gb(c.bytes)} → ≈ ${gb(c.estimado)}</span><span class="ah">${c.bytes ? (c.estimado <= c.bytes ? '−' + Math.round((1-c.estimado/c.bytes)*100) : '+' + Math.round((c.estimado/c.bytes-1)*100)) : '0'}%</span></div>`).join('');
}
if (ts.length) {
const E = ESTADO && ESTADO.cola; const corr = ts.find(t => t.estado === 'corriendo');
if (E && E.activa && E.total) { const pctTot = Math.round(((E.hechos || 0) + ((E.actual && E.actual.pct) || 0) / 100) / E.total * 100); html += `<div class="colabar"><b>Convirtiendo ${Math.min((E.hechos || 0) + 1, E.total)} de ${E.total}</b><span class="b"><i style="width:${pctTot}%"></i></span><b>${pctTot} %</b>${corr ? `<span class="mut">${esc((corr.ruta || '').split(/[\\/]/).pop())} · ${Math.round(corr.pct || 0)} %</span>` : ''}</div>`; }
else if (pend) html += `<div class="colabar" style="background:var(--panel2)"><b>${pend} en espera</b><span class="mut">la cola está parada: pulsa Reanudar</span></div>`;
html += `<div class="cabsec">Cola de conversión<span class="sep"></span>${ts.length}</div>`;
html += ts.slice(0, 120).map(t => `<div class="cfila"><span></span><span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}</span><span class="nom" title="${esc(t.ruta)}"><span class="clip">${esc((t.ruta || '').split(/[\\/]/).pop())} <span class="mut" style="font-weight:500;font-size:11px">· ${t.motor === 'resolve' ? 'Resolve' : (MOTORES && MOTORES.formatos[t.formato || 'h265_10bit'] === 'gpu' ? 'GPU' : 'CPU')} · ${esc(((ESTADO.formatos || {})[t.formato || 'h265_10bit'] || {}).nombre || t.formato || '').split(' · ')[0]}</span></span><span class="sub2">${t.estado === 'error' ? `<span class="bad">${esc((t.log || '').slice(-110))}</span>` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `<span class="mut">${esc(t.log)}</span>` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}</span>${t.estado === 'corriendo' ? `<div class="bar"><i style="width:${Math.round(t.pct || 0)}%"></i></div>` : ''}</span><span class="tam">${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}</span><span class="ah">${t.bytes_out ? (t.bytes_out <= t.bytes_in ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) : '+' + Math.round((t.bytes_out / t.bytes_in - 1) * 100)) + '%' : ''}</span></div>`).join('') + (ts.length > 120 ? `<div class="mut" style="padding:8px 0">… y ${ts.length - 120} más</div>` : '');
}
$('#convLista').innerHTML = html || '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>';
$$('#convLista input[data-i]').forEach(i => i.onchange = () => { CAND[+i.dataset.i].sel = i.checked; resumenCand(); });
const ct = $('#candTodos'); if (ct) ct.onchange = e => { CAND.forEach(c => c.sel = e.target.checked && !c.excluido && !c.en_cola); cargarCola(); };
}
$('#btnColaToggle').onclick = () => accion($('#btnColaToggle'), async () => { await api('/api/optimizar/control', {accion: $('#btnColaToggle').textContent === 'Detener' ? 'detener' : 'iniciar'}); await cargarEstado(); cargarCola(); });
const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/cola'); const raiz = raizActual().toLowerCase(); return d.trabajos.filter(t => est.includes(t.estado) && (t.raiz || '').toLowerCase() === raiz && !(est.includes('verificado') && (t.formato || '').startsWith('prores'))).map(t => t.id); };
$('#btnVerificar').onclick = () => accion($('#btnVerificar'), async () => { const ids = await idsPorEstado('listo'); const r = await api('/api/optimizar/verificar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} verificados.`, ok === r.resultados.length ? 'ok' : 'bad'); if (ok < r.resultados.length) modal('Verificación', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'FALLA'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); });
$('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `<p><b>${ids.length}</b> originales se apartan a <span class="path">_Para borrar/originales</span> dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.</p><p>Cuando estés seguro, una persona vacía _Para borrar a mano.</p>`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; });
$('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); });
$('#btnQuitar').onclick = () => accion($('#btnQuitar'), async () => { const ids = await idsPorEstado('error', 'cancelado', 'reemplazado'); await api('/api/optimizar/control', {accion: 'quitar', ids}); cargarCola(); });
// Con la cola parada, las que esperan también se pueden quitar (antes no había forma desde la interfaz, y
// bloqueaban Desconectar). No toca archivos: solo las saca de la cola.
$('#btnQuitarEspera').onclick = () => accion($('#btnQuitarEspera'), async () => { const ids = await idsPorEstado('pendiente'); if (!ids.length) throw new ApiError('No hay conversiones en espera de esta carpeta.'); modal('Quitar las conversiones en espera', `<p><b>${ids.length}</b> conversión${ids.length > 1 ? 'es' : ''} en espera se quita${ids.length > 1 ? 'n' : ''} de la cola. No se toca ningún archivo: los originales siguen donde están y puedes volver a encolarlas desde «Buscar clips».</p>`, () => accion($('#btnQuitarEspera'), async () => { await api('/api/optimizar/control', {accion: 'quitar', ids}); await cargarEstado(); cargarCola(); })); $('#modalSi').textContent = 'Quitar'; });
// ---------------- respaldar ----------------
function pintarRespaldo(){
const e = ESTADO.respaldo || {};
$('#btnRespDetener').disabled = e.estado !== 'corriendo';
const cv = CARPETA && CARPETA.convertir; const rawSin = cv && cv.raw_bytes && !((cv.trabajos || {}).reemplazado || {}).n;
$('#respAviso').innerHTML = rawSin ? `<div class="hint warn">Esta carpeta tiene ${gb(cv.raw_bytes)} en BRAW sin convertir. El respaldo compacto <b>no copia originales de cámara</b> salvo que marques «incluir BRAW / RAW sin convertir». Si quieres el respaldo ligero, pasa antes por <b>Convertir a 10 bits</b>.</div>` : '';
if (!e.estado) return;
const pct = e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : 0;
$('#respBarra').style.width = pct + '%';
const vel = e.inicio && e.bytes ? e.bytes / Math.max(1, (Date.now()/1000 - e.inicio)) : 0;
$('#respEstado').innerHTML = e.estado === 'corriendo' ? `<span class="spin"></span> ${e.hechos}/${e.total} · ${gb(e.bytes)} de ${gb(e.bytes_total)} · ${gb(vel)}/s · <span class="cell-small">${esc((e.actual||'').slice(-70))}</span>` : `${esc(e.estado)}: ${e.copiados} copiados, ${e.saltados} ya estaban, ${e.errores_n} errores → ${esc(e.destino)}`;
if (e.errores_n) api('/api/respaldo/estado').then(x => { $('#respErrores').textContent = x.errores.join('\n'); }).catch(() => {}); else $('#respErrores').textContent = 'ninguno';
}
$('#btnRespCalc').onclick = () => accion($('#btnRespCalc'), async () => {
const dest = $('#respDestino').value.trim(); if (!dest) throw new ApiError('Indica la carpeta de destino');
$('#respEstado').innerHTML = '<span class="spin"></span> calculando…';
RESP = await api(`/api/respaldo/plan?raiz=${encodeURIComponent(raizActual())}&destino=${encodeURIComponent(dest)}&por_tipo=${$('#respTipo').checked ? 1 : 0}&sin_stock=${$('#respSinStock').checked ? 1 : 0}&incluir_raw=${$('#respRaw').checked ? 1 : 0}&meses=${$('#respMeses').value}`);
RESP.raiz = raizActual();
$('#respEstado').textContent = `se copiaría a ${RESP.base}`;
$('#respCards').innerHTML = [[num(RESP.n), 'archivos a copiar'], [gb(RESP.bytes), 'peso del respaldo'], [RESP.convertidos, 'clips ya en H.265 10 bits'], [gb(RESP.raw_omitidos.bytes), `originales de cámara NO copiados (${RESP.raw_omitidos.n})`], [gb(RESP.stock_omitido.bytes), `stock de Envato omitido (${RESP.stock_omitido.n})`]].concat(RESP.reciente_omitido && RESP.reciente_omitido.meses ? [[gb(RESP.reciente_omitido.bytes), `de menos de ${RESP.reciente_omitido.meses} meses, no se copia aún (${num(RESP.reciente_omitido.n)})`]] : []).map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join('');
$('#respTipos').innerHTML = `<table>${RESP.por_tipo.map(t => `<tr><td>${esc(t.tipo)}</td><td class="num">${num(t.n)}</td><td class="num">${gb(t.bytes)}</td></tr>`).join('')}</table>`;
$('#respRawLista').innerHTML = RESP.raw_omitidos.n ? `<ul class="ejemplos" style="margin:0;padding-left:16px">${RESP.raw_omitidos.ejemplos.map(x => `<li>${esc(x.rel)} · ${gb(x.bytes)}</li>`).join('')}${RESP.raw_omitidos.n > 15 ? `<li>… y ${RESP.raw_omitidos.n - 15} más</li>` : ''}</ul>` : '<span class="ok">Ninguno: todo lo de cámara ya tiene su versión 10 bits (o se incluye tal cual).</span>';
$('#respMuestra').innerHTML = RESP.muestra.map(m => `${esc(m.de)} <span class="arrow">→</span> ${esc(m.a)}`).join('<br>');
bloquear('btnRespCopiar', RESP.n ? '' : 'No hay nada que copiar');
if (CARPETA) { CARPETA.respaldo.plan = {n: RESP.n, bytes: RESP.bytes}; pintarIndice(); }
});
$('#btnRespCopiar').onclick = () => { if (!RESP || RESP.raiz !== raizActual()) return aviso('Calcula el respaldo de esta carpeta primero.', 'bad'); modal('Copiar respaldo', `<p>Se copian <b>${num(RESP.n)}</b> archivos (${gb(RESP.bytes)}) a <span class="path">${esc(RESP.base)}</span>. Lo que ya esté igual se salta. Puedes detenerlo y retomarlo luego.</p>`, () => accion($('#btnRespCopiar'), async () => { await api('/api/respaldo/copiar', {raiz: raizActual()}); aviso('Copia en marcha. El avance está en la barra de abajo.', 'ok'); await cargarEstado(); pintarRespaldo(); })); $('#modalSi').textContent = 'Copiar'; };
$('#btnRespDetener').onclick = () => accion($('#btnRespDetener'), async () => { await api('/api/respaldo/detener', {}); });
// ---------------- historial ----------------
async function cargarBit(){
const [d, p] = await Promise.all([api('/api/bitacora'), raizActual() ? api('/api/papelera?raiz=' + encodeURIComponent(raizActual())) : null]);
const raiz = raizActual().toLowerCase(); const solo = $('#bitSoloRaiz').checked;
const lotes = d.lotes.filter(l => !solo || !raiz || (l.raiz || '').toLowerCase() === raiz);
const pl = $('#papeleraLinea');
if (p && p.subcarpetas && p.subcarpetas.length) { pl.style.display = ''; pl.innerHTML = `Apartado en <span class="path">_Para borrar</span>: <b>${gb(p.bytes)}</b> en ${p.subcarpetas.map(s => `${esc(s.nombre)} (${gb(s.bytes)})`).join(', ')}. La app no lo borra: lo revisa y lo borra una persona. <button class="lnk" id="btnAbrirPap">Abrir en el Explorador</button>`; $('#btnAbrirPap').onclick = () => accion($('#btnAbrirPap'), async () => { const r = await api('/api/abrir', {ruta: p.carpeta}); if (r.ok === false) throw new ApiError(r.motivo || 'No se pudo abrir'); }); }
else pl.style.display = 'none';
const dias = {}; lotes.forEach(l => { const k = l.ts ? new Date(l.ts * 1000).toDateString() : '?'; (dias[k] = dias[k] || []).push(l); });
const hoy = new Date().toDateString(), ayer = new Date(Date.now() - 86400000).toDateString();
const titulo = k => k === hoy ? 'Hoy' : k === ayer ? 'Ayer' : new Date(k).toLocaleDateString('es-CO', {weekday: 'long', day: 'numeric', month: 'long'});
// 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>${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'));
// ---------------- ajustes ----------------
let AJ = null, CLI_ABIERTO = null;
function pintarAjustes(){
const ac = $('#actAuto');
ac.checked = (ESTADO.config.actualizaciones || {}).auto !== false;
ac.onchange = () => api('/api/config', {actualizaciones: {auto: ac.checked}}).then(cargarEstado).then(() => aviso(ac.checked ? 'Al arrancar consultará si hay versión nueva.' : 'Ya no sale a la red al arrancar.', 'ok')).catch(e => aviso(e.message, 'bad'));
if (!AJ) {
const c = ESTADO.config; AJ = {clientes: []};
Object.entries(c.clientes).forEach(([k, v]) => AJ.clientes.push({nombre: k, orig: k, tipo: v.tipo, alias: (v.alias || []).join('; '), rutas: (c.raices || []).filter(r => r.cliente === k).map(r => r.ruta)}));
(c.raices || []).filter(r => !c.clientes[r.cliente]).forEach(r => { let cl = AJ.clientes.find(x => x.nombre === r.cliente); if (!cl) { cl = {nombre: r.cliente, orig: r.cliente, tipo: r.tipo, alias: '', rutas: []}; AJ.clientes.push(cl); } cl.rutas.push(r.ruta); });
AJ.clientes.sort((a, b) => (b.rutas.length - a.rutas.length) || a.nombre.localeCompare(b.nombre));
}
const f = ($('#filtroCli').value || '').toLowerCase();
const tipos = t => Object.entries(ESTADO.tipos).map(([k,v]) => `<option value="${k}" ${k===t?'selected':''} title="${esc(v)}">${k}</option>`).join('');
$('#clientes').innerHTML = AJ.clientes.map((c, i) => ({c, i})).filter(({c}) => !f || c.nombre.toLowerCase().includes(f) || c.alias.toLowerCase().includes(f) || c.rutas.join(' ').toLowerCase().includes(f)).map(({c, i}) => `<div class="cli ${CLI_ABIERTO === i ? 'abierto' : ''}" data-i="${i}"><div class="cab2"><span class="caret">${CLI_ABIERTO === i ? '▾' : '▸'}</span><b>${esc(c.nombre) || '<span class="mut">nuevo cliente</span>'}</b><span class="tipo">${esc(c.tipo)}</span><span class="m">${c.rutas.length ? `${c.rutas.length} carpeta${c.rutas.length > 1 ? 's' : ''} · ${esc(c.rutas[0])}${c.rutas.length > 1 ? ' …' : ''}` : 'sin carpeta'} · ${c.alias.split(';').filter(x => x.trim()).length} alias</span></div><div class="cuerpo"><div class="f"><label>Nombre</label><input type="text" value="${esc(c.nombre)}" data-k="nombre"><label>Tipo de trabajo</label><select data-k="tipo">${tipos(c.tipo)}</select><label>Sus carpetas<br><span class="cell-small">una por línea</span></label><textarea data-k="rutas" placeholder="(sin carpeta registrada: pega aquí la ruta)">${esc(c.rutas.join('\n'))}</textarea><label>Otros nombres<br><span class="cell-small">separados por ;</span></label><input type="text" value="${esc(c.alias)}" data-k="alias" placeholder="DR. NOMBRE; nombre; Nombre Apellido"></div><div class="toolbar" style="margin:10px 0 0"><button class="p" data-guardar="${i}">Guardar</button><button class="bad" data-quitar="${i}">Quitar</button><span class="sep"></span><span class="mut" style="font-size:12px">${esc(ESTADO.tipos[c.tipo] || '')}</span></div></div></div>`).join('') || '<div class="mut">Ningún cliente con ese nombre.</div>';
$$('#clientes .cab2').forEach(el => el.onclick = () => { const i = +el.parentElement.dataset.i; leerAjustes(); CLI_ABIERTO = CLI_ABIERTO === i ? null : i; pintarAjustes(); });
$$('#clientes [data-guardar]').forEach(b => b.onclick = () => accion(b, async () => { leerAjustes(); await guardarClientes(); }));
$$('#clientes [data-quitar]').forEach(b => b.onclick = () => { leerAjustes(); const c = AJ.clientes[+b.dataset.quitar]; modal('Quitar cliente', `<p>Se quita <b>${esc(c.nombre)}</b> de la lista y sus ${c.rutas.length} carpeta${c.rutas.length === 1 ? '' : 's'} dejan de estar registradas. <b>No se toca ningún archivo del disco.</b></p>`, seguro(async () => { AJ.clientes.splice(+b.dataset.quitar, 1); CLI_ABIERTO = null; await guardarClientes(); })); });
$('#plantillas').innerHTML = Object.entries(ESTADO.plantillas).map(([tipo, p]) => `<details ${tipo==='contenido'?'open':''}><summary><b>${tipo}</b> · ${esc(ESTADO.tipos[tipo]||'')}</summary><table>${Object.entries(p).map(([b,v]) => `<tr><td style="width:140px"><span class="tag" style="color:${COLORES[b]||''};border-color:${COLORES[b]||''}">${esc(NOMBRE_CUBETA[b] || b)}</span></td><td><input type="text" data-t="${tipo}" data-b="${b}" value="${esc(v)}" style="width:100%" aria-label="${esc(b)}"></td></tr>`).join('')}</table></details>`).join('');
}
function leerAjustes(){
$$('#clientes .cli.abierto').forEach(el => { const c = AJ.clientes[+el.dataset.i]; if (!c) return; c.nombre = el.querySelector('[data-k=nombre]').value.trim(); c.tipo = el.querySelector('[data-k=tipo]').value; c.alias = el.querySelector('[data-k=alias]').value; c.rutas = el.querySelector('[data-k=rutas]').value.split('\n').map(x => x.trim().replace(/^"|"$/g, '')).filter(Boolean); });
}
async function guardarClientes(){
const clientes = {}, raices = [];
AJ.clientes.filter(c => c.nombre).forEach(c => { clientes[c.nombre] = {tipo: c.tipo, alias: c.alias.split(';').map(x => x.trim()).filter(Boolean)}; c.rutas.forEach(r => raices.push({ruta: r, cliente: c.nombre, tipo: c.tipo})); });
await api('/api/clientes', {clientes}); await api('/api/raices', {raices});
AJ = null; await cargarEstado(); pintarAjustes(); aviso('Guardado.', 'ok');
}
$('#filtroCli').oninput = () => { leerAjustes(); pintarAjustes(); }; // sin leer antes, el filtro borraba lo que estabas editando
$('#btnAddCliente').onclick = () => { leerAjustes(); AJ.clientes.unshift({nombre: '', orig: '', tipo: 'contenido', alias: '', rutas: []}); CLI_ABIERTO = 0; $('#filtroCli').value = ''; pintarAjustes(); const el = $('#clientes .cli.abierto [data-k=nombre]'); if (el) el.focus(); };
$('#btnGuardarPlantillas').onclick = () => accion($('#btnGuardarPlantillas'), async () => { const plantillas = {}; $$('#plantillas input').forEach(i => { plantillas[i.dataset.t] = plantillas[i.dataset.t] || {}; plantillas[i.dataset.t][i.dataset.b] = i.value.trim(); }); await api('/api/plantillas', {plantillas}); await cargarEstado(); PLAN = null; estadoPlan(); aviso('Plantillas guardadas', 'ok'); });
// ---------------- carpetas vacías ----------------
let VACIAS = null;
const cargarVacias = seguro(async () => {
const raiz = raizActual(); if (!raiz) return;
VACIAS = await api('/api/vacias?raiz=' + encodeURIComponent(raiz));
VACIAS.carpetas.forEach(c => c.sel = false);
$('#btnVaciasTodas').textContent = 'Marcar todas';
pintarVacias();
});
function pintarVacias(){
const caja = $('#vaciasCaja'), tb = $('#tablaVacias tbody');
if (!VACIAS) { caja.style.display = 'none'; $('#btnVaciasTodas').style.display = 'none'; $('#vaciasResumen').textContent = ''; $('#btnVaciasBorrar').disabled = true; $('#btnVaciasBorrarPorQue').textContent = 'Busca primero'; return; }
const hay = VACIAS.carpetas.length;
caja.style.display = hay ? '' : 'none';
$('#btnVaciasTodas').style.display = hay ? '' : 'none';
tb.innerHTML = VACIAS.carpetas.map((c, i) => `<tr><td><input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} aria-label="${esc(c.rel)}"></td><td class="path">${esc(c.rel)}</td><td class="num">${c.subcarpetas || ''}</td></tr>`).join('');
tb.querySelectorAll('input').forEach(i => i.onchange = () => { VACIAS.carpetas[+i.dataset.i].sel = i.checked; resumenVacias(); });
resumenVacias();
}
function resumenVacias(){
const n = VACIAS ? VACIAS.carpetas.filter(c => c.sel).length : 0;
$('#vaciasResumen').textContent = !VACIAS ? '' : VACIAS.carpetas.length
? `${VACIAS.carpetas.length} vacía${VACIAS.carpetas.length > 1 ? 's' : ''} · ${n} marcada${n === 1 ? '' : 's'}`
: 'No queda ninguna carpeta vacía en esta carpeta.';
$('#btnVaciasBorrar').disabled = !n;
$('#btnVaciasBorrarPorQue').textContent = !VACIAS ? 'Busca primero' : n ? '' : 'Marca alguna';
}
$('#btnVacias').onclick = () => accion($('#btnVacias'), cargarVacias);
$('#btnVaciasTodas').onclick = () => { if (!VACIAS) return; const todas = VACIAS.carpetas.every(c => c.sel); VACIAS.carpetas.forEach(c => c.sel = !todas); $('#btnVaciasTodas').textContent = todas ? 'Marcar todas' : 'Desmarcar todas'; pintarVacias(); };
$('#btnVaciasBorrar').onclick = () => {
const s = (VACIAS ? VACIAS.carpetas : []).filter(c => c.sel); if (!s.length) return;
if (VACIAS.raiz !== raizActual()) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad');
const dentro = s.reduce((a, c) => a + (c.subcarpetas || 0), 0);
modal('Borrar carpetas vacías', `<p><b>${s.length}</b> carpeta${s.length > 1 ? 's' : ''} sin un solo archivo dentro${dentro ? `, y ${dentro} subcarpeta${dentro > 1 ? 's' : ''} igual de vacía${dentro > 1 ? 's' : ''} que cuelga${dentro > 1 ? 'n' : ''} de ella${s.length > 1 ? 's' : ''}` : ''}, se borran del disco.</p><p>No hay nada dentro que perder. Lo único que se va es el nombre, y <b>Deshacer</b> las vuelve a crear tal cual:</p><div class="ejtipo">${s.slice(0, 8).map(c => `<div><b>${esc(c.rel)}</b></div>`).join('')}${s.length > 8 ? `<div><span>… y ${s.length - 8} más</span></div>` : ''}</div>`, () => accion($('#btnVaciasBorrar'), async () => {
const r = await api('/api/vacias/borrar', {raiz: raizActual(), rutas: s.map(c => c.ruta)});
aviso(`${r.borradas} carpeta${r.borradas === 1 ? '' : 's'} vacía${r.borradas === 1 ? '' : 's'} borrada${r.borradas === 1 ? '' : 's'}.` + (r.errores.length ? ` ${r.errores.length} se saltaron.` : ''),
r.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(r.lote)});
await cargarVacias(); await cargarEstado();
}));
$('#modalSi').textContent = 'Borrar';
};
// ---------------- mapa: cómo van a quedar las carpetas ----------------
const SEP = '\\';
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {}, MAPAE_DESTINO = '';
const cargarMapaEst = seguro(async (tipo) => {
const raiz = raizActual();
const sel = $('#mapaTipo');
if (!sel.options.length && ESTADO) sel.innerHTML = Object.entries(ESTADO.tipos).map(([k, v]) => `<option value="${k}">${esc(v)}</option>`).join('');
const q = [];
if (tipo || sel.value) q.push('tipo=' + encodeURIComponent(tipo || sel.value));
if (raiz) q.push('raiz=' + encodeURIComponent(raiz));
MAPAE = await api('/api/estructura' + (q.length ? '?' + q.join('&') : ''));
sel.value = MAPAE.tipo;
MAPAE_NODOS = {}; MAPAE_SEL = '';
anotarMapa(MAPAE.arbol, '');
pintarMapaEst();
});
function anotarMapa(n, base){
n._r = base; n._id = 'm' + Object.keys(MAPAE_NODOS).length; MAPAE_NODOS[n._id] = n;
(n.hijos || []).forEach(h => anotarMapa(h, (base ? base + SEP : '') + h.nombre));
}
function nodoHtml(n){
const cif = n.n ? `${num(n.n)} · ${gb(n.bytes)}` : '';
const que = [n.variable && ('Cambia con el material: ' + n.variable), n.nota].filter(Boolean).join('. ');
const hit = MAPAE_DESTINO && (n.cubetas || []).includes(MAPAE_DESTINO);
return `<div><div class="nd ${MAPAE_SEL === n._id ? 'sel' : ''} ${hit ? 'hit' : ''} ${n.n ? '' : 'vacio'} ${n.variable ? 'var' : ''}" data-id="${n._id}" tabindex="0" role="button"${que ? ` title="${esc(que)}"` : ''}>` +
`<i class="pt"${n.color ? ` style="background:${n.color}"` : ''}></i>` +
`<span class="nm">${esc(n.nombre)}${SEP}</span>` +
`${cif ? `<span class="cif">${cif}</span>` : ''}</div>` +
`${n.hijos && n.hijos.length ? `<div class="hijos">${n.hijos.map(nodoHtml).join('')}</div>` : ''}</div>`;
}
function pintarMapaEst(){
if (!MAPAE) return;
$('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo;
$('#mapaArbol').classList.remove('mut');
const cols = MAPAE.cubetas.filter(c => c.plantilla && c.n).sort((a, b) => b.bytes - a.bytes);
$('#mapaArbol').innerHTML = nodoHtml(MAPAE.arbol) +
'<div class="leyenda">Las carpetas <i>en cursiva</i> cambian con el material: el año, el mes, el nombre de la pieza o el programa. Toca cualquiera para ver qué guarda o cambiarle el nombre.</div>' +
(cols.length ? `<div class="leyenda-col">${cols.map(c => `<span><i style="background:${c.color}"></i>${esc(NOMBRE_CUBETA[c.clave] || c.titulo)}</span>`).join('')}</div>` : '');
$('#btnDespintar').style.display = MAPAE.pintado ? '' : 'none';
$('#btnPintar').textContent = MAPAE.pintado ? 'Volver a pintar' : 'Pintar las carpetas en Windows';
$('#mapaEjemplos').innerHTML = MAPAE.cubetas.filter(c => c.ejemplos.length).slice(0, 8)
.map(c => `<button data-ej="${esc(c.ejemplos[0])}">${esc(c.ejemplos[0])}</button>`).join('');
$$('#mapaEjemplos button').forEach(b => b.onclick = () => { $('#mapaArchivo').value = b.dataset.ej; probarArchivo(); });
$$('#mapaArbol .nd').forEach(el => {
const ir = () => { MAPAE_SEL = el.dataset.id; pintarMapaEst(); };
el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } };
});
const con = MAPAE.cubetas.filter(c => c.n).length;
$('#mapaResumen').textContent = MAPAE.raiz ? `${con} de ${MAPAE.cubetas.filter(c => c.asignable).length} tipos de material tienen algo hoy en esta carpeta` : 'Conecta una carpeta para ver cuánto material tiene cada sitio';
pintarMapaDetalle();
pintarSinSitio();
}
function pintarMapaDetalle(){
const caja = $('#mapaDetalle'), n = MAPAE_NODOS[MAPAE_SEL];
if (!n) { caja.className = 'det mut'; caja.innerHTML = 'Toca una carpeta del árbol para ver qué guarda y cambiar dónde va.'; return; }
caja.className = 'det';
const cubs = (n.cubetas || []).map(c => MAPAE.cubetas.find(x => x.clave === c)).filter(Boolean);
let h = `<h4>${esc(n.nombre)}</h4><div class="ruta">${esc((MAPAE.arbol.nombre || '') + SEP + n._r)}</div>`;
if (n.crudo && n.crudo.length && !n.variable)
h += `<div class="toolbar" style="margin:0 0 10px"><input type="text" id="mapaNombre" value="${esc(n.nombre)}" style="flex:1;min-width:150px" aria-label="Nombre de la carpeta"><button class="p" id="btnRenombrar">Cambiar el nombre</button></div>`;
if (n.variable) h += `<p class="mut" style="font-size:12.5px">El nombre cambia con el material: es ${esc(n.variable)}.</p>`;
if (n.nota) h += `<p class="mut" style="font-size:12.5px">${esc(n.nota.charAt(0).toUpperCase() + n.nota.slice(1))}.</p>`;
if (n.apps_de){
const c = MAPAE.cubetas.find(x => x.clave === n.apps_de);
if (c && c.apps.length) h += `<p style="margin:10px 0 0"><b>Una carpeta por programa:</b></p><div class="ejtipo">${c.apps.map(a => `<div><b>${esc(a.nombre)}</b><span>${num(a.n)} · ${gb(a.bytes)}</span></div>`).join('')}</div>`;
}
if (!cubs.length && !n.apps_de) h += '<p class="mut" style="font-size:12.5px">Aquí no cae nada directamente: es un nivel intermedio, lo que hay está en las carpetas de dentro.</p>';
cubs.forEach(c => {
h += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--line2)"><b>${esc(c.titulo)}</b>`;
h += `<ul>${c.guarda.map(g => `<li>${esc(g)}</li>`).join('')}</ul>`;
if (c.apps && c.apps.length) h += `<div class="ejtipo" style="margin-top:8px">${c.apps.map(a => `<div><b>${esc(a.nombre)}</b><span>${num(a.n)} · ${gb(a.bytes)}</span></div>`).join('')}</div>`;
h += `<p class="mut" style="font-size:12.5px;margin:8px 0 4px">${MAPAE.raiz ? (c.n ? `Hoy en esta carpeta: <b>${num(c.n)}</b> archivos · <b>${gb(c.bytes)}</b>.` : 'Hoy no tienes nada de esto en esta carpeta.') : ''}</p>`;
h += `<div class="toolbar" style="margin-top:6px"><input type="text" class="path" data-pl="${esc(c.clave)}" value="${esc(c.plantilla)}" style="flex:1;min-width:200px" aria-label="Dónde va ${esc(c.titulo)}">`;
h += `<button class="p" data-guardarpl="${esc(c.clave)}">Guardar</button>`;
if (c.defecto && c.plantilla !== c.defecto) h += `<button class="lnk" data-defecto="${esc(c.clave)}">Volver al de fábrica</button>`;
h += '</div></div>';
});
if (cubs.length) h += `<div class="vars" style="margin-top:10px">Puedes usar: ${Object.entries(MAPAE.variables).map(([k, v]) => `<div><code>{${k}}</code> — ${esc(v)}</div>`).join('')}</div>`;
caja.innerHTML = h;
engancharPlantillas(caja);
const bn = $('#btnRenombrar');
if (bn) {
const pedir = () => renombrarCarpeta(n, $('#mapaNombre').value.trim());
bn.onclick = pedir;
$('#mapaNombre').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); pedir(); } };
}
}
function pintarSinSitio(){
const sin = MAPAE.cubetas.filter(c => c.asignable && !c.plantilla);
$('#mapaSinSitio').style.display = sin.length ? '' : 'none';
if (!sin.length) return;
$('#mapaSinSitioLista').innerHTML = sin.map(c => `<div class="sinsitio"><b>${esc(c.titulo)}</b><span class="mut" style="font-size:12px">${c.n ? `${num(c.n)} · ${gb(c.bytes)}` : 'sin material hoy'}</span><input type="text" class="path" data-pl="${esc(c.clave)}" value="${esc(c.defecto)}" style="flex:1;min-width:200px" aria-label="Dónde va ${esc(c.titulo)}"><button class="p" data-guardarpl="${esc(c.clave)}">Añadir</button></div>`).join('');
engancharPlantillas($('#mapaSinSitioLista'));
}
function engancharPlantillas(caja){
caja.querySelectorAll('[data-guardarpl]').forEach(b => b.onclick = () => accion(b, async () => {
const clave = b.dataset.guardarpl;
const campo = caja.querySelector(`input[data-pl="${clave}"]`);
await guardarPlantilla(clave, campo.value.trim());
}));
caja.querySelectorAll('[data-defecto]').forEach(b => b.onclick = () => accion(b, async () => {
const c = MAPAE.cubetas.find(x => x.clave === b.dataset.defecto);
await guardarPlantilla(c.clave, c.defecto);
}));
}
async function guardarPlantilla(clave, valor){
await api('/api/plantillas', {plantillas: {[MAPAE.tipo]: {[clave]: valor}}});
PLAN = null; estadoPlan();
await cargarEstado();
// se vuelve a la MISMA carpeta por su ruta, no por su posicion: al cambiar la plantilla el arbol
// cambia de forma y el numero de nodo pasa a ser el de otra rama
const antes = (MAPAE_NODOS[MAPAE_SEL] || {})._r;
await cargarMapaEst(MAPAE.tipo);
const nodos = Object.values(MAPAE_NODOS);
// se salta a donde haya quedado lo que acabas de cambiar; si esa cubeta ya no cae en ninguna carpeta
// (la dejaste en blanco), se vuelve a la misma carpeta de antes
const vuelta = nodos.find(n => (n.cubetas || []).includes(clave)) || nodos.find(n => n._r === antes);
if (vuelta) { MAPAE_SEL = vuelta._id; pintarMapaEst(); }
aviso('Guardado. El plan se vuelve a calcular con el destino nuevo.', 'ok');
}
// accion() pinta un spinner dentro del elemento: en un <select> eso se llevaría por delante sus opciones
$('#mapaTipo').onchange = () => cargarMapaEst($('#mapaTipo').value);
// ---- renombrar una carpeta del mapa ----
const renombrarCarpeta = seguro(async (nodo, nombre) => {
if (!nombre || nombre === nodo.nombre) return;
const raiz = raizActual();
let existen = {n: 0, carpetas: []};
if (raiz) existen = await api(`/api/estructura/existen?raiz=${encodeURIComponent(raiz)}&ruta=${encodeURIComponent(JSON.stringify(nodo.crudo))}`);
const cuenta = existen.n;
modal('Cambiar el nombre de la carpeta', `<p>A partir de ahora, lo que iba a <span class="path">${esc(nodo.nombre)}</span> va a ir a <span class="path">${esc(nombre)}</span>.</p>` +
(cuenta ? `<label style="display:flex;gap:8px;align-items:flex-start;margin-top:10px;font-size:13px"><input type="checkbox" id="mDisco" checked style="margin-top:3px"><span>Cambiarle el nombre también a ${cuenta === 1 ? 'la carpeta que ya existe' : `las <b>${cuenta}</b> carpetas que ya existen`} con ese nombre. Es un movimiento normal: se deshace desde Historial.</span></label><div class="ejtipo" style="margin-top:8px">${existen.carpetas.slice(0, 6).map(c => `<div><b>${esc(c)}</b></div>`).join('')}${cuenta > 6 ? `<div><span>… y ${cuenta - 6} más</span></div>` : ''}</div>`
: '<p class="mut" style="font-size:12.5px">No hay ninguna carpeta con ese nombre en el disco todavía; solo cambia lo que la app hará de ahora en adelante.</p>'), () => accion($('#btnRenombrar') || $('#mapaTipo'), async () => {
const r = await api('/api/estructura/renombrar', {tipo: MAPAE.tipo, ruta: nodo.crudo, nombre,
raiz: raiz || null, en_disco: cuenta ? $('#mDisco').checked : false});
if (r.error) throw new ApiError(r.error);
PLAN = null; estadoPlan();
await cargarEstado();
await cargarMapaEst(MAPAE.tipo);
const vuelta = Object.values(MAPAE_NODOS).find(x => x.nombre === nombre);
if (vuelta) { MAPAE_SEL = vuelta._id; pintarMapaEst(); }
aviso(r.movidas ? `Ahora se llama «${nombre}»; ${r.movidas} carpeta${r.movidas > 1 ? 's' : ''} renombrada${r.movidas > 1 ? 's' : ''} en el disco.` : `Ahora se llama «${nombre}».`,
(r.errores || []).length ? 'bad' : 'ok', r.movidas ? {texto: 'Ver historial', ir: () => irA('historial')} : null);
}));
$('#modalSi').textContent = 'Cambiar el nombre';
});
// ---- probar dónde iría un archivo ----
let PROBAR_T = null;
const probarArchivo = seguro(async () => {
const nombre = $('#mapaArchivo').value.trim();
const caja = $('#mapaRespuesta');
if (!nombre) { caja.innerHTML = ''; MAPAE_DESTINO = ''; pintarMapaEst(); return; }
const d = await api(`/api/estructura/archivo?nombre=${encodeURIComponent(nombre)}&tipo=${encodeURIComponent(MAPAE ? MAPAE.tipo : 'contenido')}`);
if (d.error) { caja.innerHTML = `<p class="mut">${esc(d.error)}</p>`; return; }
const raizN = MAPAE && MAPAE.arbol ? MAPAE.arbol.nombre : '';
const fecha = d.por_fecha === 'mtime'
? 'el nombre no trae fecha, así que el mes lo saca de la fecha del archivo'
: `el nombre dice que es del ${d.fecha}`;
const dia = d.dia ? ` Y si ese mes hubo más de un rodaje, dentro de «${d.dia}».` : '';
caja.innerHTML = `<div class="resp"><i class="pt" style="background:${d.color}"></i><div>` +
`<div><b>${esc(d.nombre)}</b> — ${esc(d.que)}${d.camara ? ` · cámara ${esc(d.camara)}` : ''}${d.app ? ` · proyecto de ${esc(d.app)}` : ''}</div>` +
`<div class="d" style="margin:4px 0">${esc(raizN + SEP + d.destino)}${SEP}</div>` +
`<div class="mut" style="font-size:12px">${esc(fecha)}.${esc(dia)} Si el archivo está dentro de una carpeta que la app reconoce, esa carpeta puede mandar sobre esto.</div>` +
'</div></div>';
MAPAE_DESTINO = d.cubeta;
pintarMapaEst();
const hit = $('#mapaArbol .nd.hit');
if (hit) hit.scrollIntoView({block: 'center', behavior: 'smooth'});
});
$('#mapaArchivo').oninput = () => { clearTimeout(PROBAR_T); PROBAR_T = setTimeout(probarArchivo, 250); };
// ---- colores en el Explorador ----
$('#btnPintar').onclick = () => {
const raiz = raizActual(); if (!raiz) return aviso('Conecta una carpeta primero.', 'bad');
modal('Pintar las carpetas en Windows', '<p>A cada carpeta se le pone un icono del color de lo que guarda, para verlo en el Explorador sin abrir la app.</p><p class="mut" style="font-size:12.5px">Windows no tiene «color de carpeta», así que se hace por el mismo camino que «Propiedades › Personalizar › Cambiar icono»: un <span class="path">desktop.ini</span> y los iconos en una carpeta oculta dentro del cliente. No se mueve ni se borra ningún archivo, y se quita con un clic. En algunos discos de red Windows no lo permite; si pasa, te lo digo y no dejo nada escrito.</p>',
() => accion($('#btnPintar'), async () => {
const r = await api('/api/colores/pintar', {raiz});
if (r.error) throw new ApiError(r.error);
aviso(`${r.pintadas} carpetas pintadas con ${r.colores} colores.` + (r.sin_marca ? ` ${r.sin_marca} no se pudieron: el disco no lo permite.` : '') + ' Si el Explorador ya estaba abierto, pulsa F5.', 'ok');
await cargarMapaEst(MAPAE.tipo);
}));
$('#modalSi').textContent = 'Pintar';
};
$('#btnDespintar').onclick = () => accion($('#btnDespintar'), async () => {
const r = await api('/api/colores/quitar', {raiz: raizActual()});
aviso(`${r.quitadas} carpetas vueltas a su icono normal.`, 'ok');
await cargarMapaEst(MAPAE.tipo);
});
// ---------------- soltar carpeta para añadir ----------------
const zona = $('#zonaSoltar');
['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); zona.classList.add('sobre'); $('#vacioCaja').classList.add('sobre'); }));
['dragleave','drop'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); if (ev === 'drop' || e.target === document.body) { zona.classList.remove('sobre'); $('#vacioCaja').classList.remove('sobre'); } }));
document.addEventListener('drop', seguro(async e => {
e.preventDefault();
let nombre = '';
const conRuta = Array.from(e.dataTransfer.files || []).find(f => f.pywebviewFullPath);
if (conRuta) { const ruta = conRuta.pywebviewFullPath; const esArchivo = conRuta.type || /\.[a-z0-9]{1,5}$/i.test(conRuta.name); return proponerRaiz(esArchivo ? ruta.replace(/[\\/][^\\/]*$/, '') : ruta); }
const items = e.dataTransfer.items ? Array.from(e.dataTransfer.items) : [];
for (const it of items) { const ent = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (ent && ent.isDirectory) { nombre = ent.name; break; } }
if (!nombre) { const f = e.dataTransfer.files[0]; if (f && f.webkitRelativePath) nombre = f.webkitRelativePath.split('/')[0]; else if (f) nombre = f.name.replace(/\.[^.]+$/, ''); }
const texto = e.dataTransfer.getData('text');
if (!nombre && texto && /^[A-Za-z]:\\/.test(texto.trim())) return proponerRaiz(texto.trim());
if (!nombre) return aviso('Suelta una carpeta (no un archivo).', 'bad');
// dentro de la ventana de OrganiLAB (WebView2) la ruta completa llega por el puente: no hay que buscar
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);
}
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));
if (!d.coincidencias.length) return modal('No la encontré', `<p>No hay ninguna carpeta llamada <b>${esc(nombre)}</b> en los tres primeros niveles de los discos. Pega la ruta completa:</p><input type="text" id="rutaPegada" style="width:100%" placeholder="Z:\\Clientes\\${esc(nombre)}">`, () => proponerRaiz($('#rutaPegada').value.trim()));
if (d.coincidencias.length === 1) return proponerRaiz(d.coincidencias[0]);
modal('¿Cuál es?', `<p>Hay varias carpetas con ese nombre:</p>${d.coincidencias.map((c,i) => `<label style="display:block;margin:4px 0"><input type="radio" name="cand" value="${esc(c)}" ${i===0?'checked':''}> <span class="path">${esc(c)}</span></label>`).join('')}`, () => proponerRaiz(document.querySelector('input[name=cand]:checked').value));
}));
async function elegirCarpeta(btn){
await accion(btn, async () => {
const d = await api('/api/elegir-carpeta', {});
if (d.ruta) await proponerRaiz(d.ruta);
});
}
zona.onclick = () => { modal('Añadir carpeta de cliente', '<p><button class="p" type="button" id="btnElegir3">Elegir carpeta…</button> <span class="mut">abre el selector de Windows</span></p><p style="margin-top:12px">O pega la ruta completa (en el Explorador: Mayús + clic derecho → «Copiar como ruta de acceso»):</p><input type="text" id="rutaPegada" style="width:100%" placeholder="Z:\\Dermatologas\\Nombre del cliente">', () => { const v = $('#rutaPegada').value.trim().replace(/^"|"$/g, ''); if (v) proponerRaiz(v); }); $('#btnElegir3').onclick = () => { $('#modal').classList.remove('on'); elegirCarpeta($('#btnElegir')); }; };
$('#btnElegir').onclick = () => elegirCarpeta($('#btnElegir'));
$('#btnElegir2').onclick = e => { e.stopPropagation(); elegirCarpeta($('#btnElegir2')); };
zona.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); zona.onclick(); } };
$('#vacioCaja').onclick = () => zona.onclick(); $('#vacioCaja').onkeydown = zona.onkeydown;
const MESES_UI = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
const EJEMPLO_CUBETAS = [['GRABACION', 'lo que sale de la cámara'], ['PIEZA', 'cada pieza, con su edición'],
['EXPORTADOS', 'lo que ya se entregó'], ['MUSICA', 'música y sonidos de siempre'],
['BASURA', 'lo que sobra, para que lo borres tú']];
function ejemploTipo(ruta, tipo){
// se arma con la plantilla de verdad, así que el ejemplo es exactamente lo que la app va a hacer
const pl = (ESTADO.plantillas || {})[tipo] || {}, h = new Date();
const val = {anio: h.getFullYear(), mes: `${String(h.getMonth() + 1).padStart(2, '0')}- ${MESES_UI[h.getMonth()]}`,
nombre: 'Nombre de la pieza', sub: '', fuente: '', rel: ''};
const base = (ruta || '').replace(/[\\/]+$/, ''), visto = new Set(), filas = [];
for (const [cub, que] of EJEMPLO_CUBETAS){
const dest = (pl[cub] || '').replace(/\{(\w+)\}/g, (_, k) => val[k] !== undefined ? String(val[k]) : '')
.replace(/\/+$/, '').replace(/\/+/g, '\\');
if (!dest || visto.has(dest)) continue;
visto.add(dest);
filas.push(`<div><b>${esc(base)}\\${esc(dest)}\\</b><span>${que}</span></div>`);
if (filas.length === 4) break;
}
return filas.join('');
}
function proponerRaiz(ruta){
if (!ruta) return;
const clientes = Object.keys(ESTADO.config.clientes);
const nombre = ruta.replace(/[\\/]+$/, '').split(/[\\/]/).pop();
const sugerido = clientes.find(c => (ESTADO.config.clientes[c].alias || []).concat(c).some(a => a.toLowerCase() === nombre.toLowerCase())) || nombre;
const tipoDe = c => ((ESTADO.config.clientes[c] || {}).tipo) || 'contenido';
modal('Añadir carpeta de cliente', `<p class="path">${esc(ruta)}</p>
<div class="preg"><label for="nuevoCliente">¿De quién es este material?</label>
<input type="text" id="nuevoCliente" value="${esc(sugerido)}" list="lcli2" style="width:100%">
<datalist id="lcli2">${clientes.map(c => `<option value="${esc(c)}">`).join('')}</datalist>
<span>Con ese nombre reúne todas sus carpetas, aunque en cada disco estén escritas distinto.</span></div>
<div class="preg"><label for="nuevoTipo">¿Qué clase de trabajo le haces?</label>
<select id="nuevoTipo" style="width:100%">${Object.entries(ESTADO.tipos).map(([k,v]) => `<option value="${k}">${esc(v)}</option>`).join('')}</select>
<span>Es lo único que decide en qué carpetas va a guardar su material. Se cambia luego en Ajustes.</span></div>
<p style="margin:14px 0 0"><b>Así va a quedar ordenada:</b></p>
<div class="ejtipo" id="ejemploTipo"></div>
<p class="mut" style="margin-top:12px">Al confirmar solo la lee y te arma una lista de lo que haría. <b>No mueve ni un archivo</b> hasta que tú lo apliques.</p>`, seguro(async () => {
const cliente = $('#nuevoCliente').value.trim() || nombre, tipo = $('#nuevoTipo').value;
const raices = ESTADO.config.raices.filter(r => r.ruta.toLowerCase() !== ruta.toLowerCase()); raices.push({ruta, cliente, tipo});
await api('/api/raices', {raices});
if (!ESTADO.config.clientes[cliente]) { ESTADO.config.clientes[cliente] = {tipo, alias: [nombre]}; await api('/api/clientes', {clientes: ESTADO.config.clientes}); }
await cargarEstado(); const nueva = ESTADO.raices.find(r => r.ruta === ruta); $('#raiz').value = nueva ? nueva.clave : ruta; $('#raiz').onchange();
await api('/api/escanear', {raiz: raizActual()}); await cargarEstado(); irA('carpeta');
aviso('Carpeta añadida. Escaneando…', 'ok');
}));
const sel = $('#nuevoTipo'), inp = $('#nuevoCliente');
const pinta = () => $('#ejemploTipo').innerHTML = ejemploTipo(ruta, sel.value);
sel.value = tipoDe(sugerido); sel.onchange = pinta;
// si escribe el nombre de un cliente que ya existe, manda el tipo que ese cliente ya tiene: dos
// carpetas del mismo cliente ordenadas con plantillas distintas serían un lío
inp.oninput = () => { const c = ESTADO.config.clientes[inp.value.trim()]; if (c && c.tipo && c.tipo !== sel.value) { sel.value = c.tipo; pinta(); } };
pinta();
}
// ---------------- asistente ----------------
async function esperarEscaneo(raiz){
for (;;) { await cargarEstado(); const r = ESTADO.raices.find(x => x.clave === raiz); const p = r && r.progreso; if (!p || p.estado !== 'corriendo') return p; await new Promise(r => setTimeout(r, 700)); }
}
const asistente = seguro(async (raiz, escanearAntes) => {
raiz = raiz || raizActual(); if (!raiz) return;
const r = ESTADO.raices.find(x => x.clave === raiz);
if (escanearAntes || !(r && r.escaneo)) { await api('/api/escanear', {raiz}); const p = await esperarEscaneo(raiz); if (p && p.estado === 'error') throw new ApiError('No se pudo escanear: ' + p.error); }
modal('Asistente', '<span class="spin"></span> pensando el diagnóstico y el plan…');
const d = await api('/api/asistente/pasos?raiz=' + encodeURIComponent(raiz));
const dg = d.diagnostico;
let html = `<p class="mut">${num(dg.n)} archivos · ${gb(dg.bytes)}</p><div class="asis-diag">` +
dg.grupos.map(g => `<b>${gb(g.bytes)}</b><span class="mut">${g.pct} %</span><span title="${esc(g.que_es)}">${esc(g.titulo)} <span class="mut">(${num(g.n)}${g.desde ? ' · ' + esc(g.desde) + (g.hasta && g.hasta !== g.desde ? ' → ' + esc(g.hasta) : '') : ''})</span></span>`).join('') + '</div>' +
dg.frases.map(f => `<div class="asis-frase">• ${esc(f)}</div>`).join('') +
`<p style="margin-top:10px"><b>¿Qué hacemos con cada cosa?</b> <span class="mut">${d.movimientos} movimientos posibles</span></p>` +
d.pasos.map(p => `<div class="asis-paso"><h4>${esc(p.titulo)}</h4><div class="mut" style="font-size:12px">${esc(p.resumen)}</div>` +
p.opciones.map(([k, t]) => `<label><input type="radio" name="asis_${p.clave}" value="${k}" ${k === p.defecto ? 'checked' : ''}> ${esc(t)}</label>`).join('') + '</div>').join('') +
'<p class="mut" style="font-size:12px">Nada se borra: lo sobrante va a _Para borrar dentro de la misma carpeta y cada cambio se deshace desde Historial.</p>';
modal('Asistente · ' + raiz.split(/[\\/]/).pop(), html, seguro(async () => {
const decisiones = {}; d.pasos.forEach(p => { const el = document.querySelector(`input[name=asis_${p.clave}]:checked`); decisiones[p.clave] = el ? el.value : p.defecto; });
const r = await api('/api/asistente', {raiz, decisiones}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar');
aviso('El asistente está trabajando. Sigue el avance en la barra de abajo.', 'ok'); await cargarEstado(); cargarVista();
}));
$('#modalSi').textContent = 'Empezar';
});
const NOMBRE_PASO = {plan: 'pensando el plan', ordenar: 'ordenando carpetas y archivos', stock: 'apartando stock viejo', duplicados: 'juntando repetidos', analizar: 'leyendo los clips', convertir: 'convirtiendo a 10 bits', respaldo: 'copiando el respaldo', fin: 'listo'};
// ---------------- modal ----------------
function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
document.addEventListener('keydown', e => { if (e.key === 'Escape' && $('#modal').classList.contains('on')) $('#modal').classList.remove('on'); });
$('#modal').addEventListener('click', e => { if (e.target === $('#modal')) $('#modal').classList.remove('on'); });
// ---------------- actualización automática ----------------
$('#btnBuscarAct').onclick = () => accion($('#btnBuscarAct'), async () => {
const a = await api('/api/actualizacion/buscar', {});
await cargarEstado();
if (a.disponible) { aviso(`Hay una versión nueva: ${a.disponible.version}. Tienes la ${a.version}.`, 'ok', {texto: 'Actualizar', ir: () => { irA('carpeta'); const b = $('#btnActualizar'); if (b) b.click(); }}); irA('carpeta'); window.scrollTo(0, 0); }
else if (a.error) throw new ApiError(a.error);
else aviso(`Ya tienes la última versión (${a.version}).`, 'ok');
});
let ACT_SIGUIENDO = false;
function pintarActualizacion(){
const a = ESTADO.actualizacion || {}; const caja = $('#actualizacionCaja');
if (a.aplicando || a.estado === 'descargando' || a.estado === 'verificando' || a.estado === 'reiniciando') { if (!ACT_SIGUIENDO) seguirActualizacion(); return; }
if (!a.disponible) { caja.style.display = 'none'; return; }
caja.style.display = ''; caja.classList.add('actualizacion');
caja.innerHTML = `<h3>Hay una versión nueva: ${esc(a.disponible.version)} <span class="mut">(tienes la ${esc(ESTADO.version)})</span></h3><p class="mut">${esc(a.disponible.notes || '')}</p><div class="toolbar"><button id="btnActualizar" class="p">Actualizar ahora</button><span class="mut">${(a.disponible.size / 1e6).toFixed(0)} MB · el programa se cierra y vuelve a abrirse solo; tus datos no se tocan. No lo hagas con una conversión o copia en marcha.</span></div>` + (a.error ? `<p class="bad">${esc(a.error)}</p>` : '');
$('#btnActualizar').onclick = () => accion($('#btnActualizar'), async () => { if (huella(ESTADO).cola || huella(ESTADO).resp || huella(ESTADO).asis) throw new ApiError('Hay una tarea en marcha. Espera a que termine antes de actualizar.'); const r = await api('/api/actualizacion/instalar', {}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar la actualización'); seguirActualizacion(); });
}
async function seguirActualizacion(){
ACT_SIGUIENDO = true; const caja = $('#actualizacionCaja'); caja.style.display = ''; irA('carpeta');
for (;;) {
let a; try { a = await api('/api/actualizacion'); } catch (e) { a = null; }
if (!a) { caja.innerHTML = '<h3>Reiniciando…</h3><p class="mut">El programa se está sustituyendo por la versión nueva. La página se recargará sola.</p>'; await new Promise(r => setTimeout(r, 2000)); try { const e = await fetch('/api/estado'); if (e.ok) location.reload(); } catch (e) {} continue; }
if (a.estado === 'error') { caja.innerHTML = `<h3>No se pudo actualizar</h3><p class="bad">${esc(a.error)}</p>`; ACT_SIGUIENDO = false; return; }
if (a.estado === 'reiniciando' || a.aplicando) { caja.innerHTML = '<h3>Instalando…</h3><p class="mut">El programa se cierra y vuelve a abrirse con la versión nueva. La página se recargará sola.</p>'; await new Promise(r => setTimeout(r, 2000)); continue; }
caja.innerHTML = `<h3>${a.estado === 'verificando' ? 'Verificando' : 'Descargando'} la versión ${esc(a.disponible ? a.disponible.version : '')}</h3><span class="barra" style="width:100%"><i style="width:${a.pct}%"></i></span><p class="mut">${(a.bytes / 1e6).toFixed(0)} / ${(a.total / 1e6).toFixed(0)} MB${a.error ? ' · ' + esc(a.error) : ''}</p>`;
await new Promise(r => setTimeout(r, 700));
}
}
// ---------------- arranque ----------------
cargarEstado().then(() => { limpiarTablas(); cargarVista('carpeta'); }).catch(e => { aviso(e.message, 'bad'); });
</script>
</body>
</html>