Files
labstream-organizador/static/index.html
2026-09-15 19:52:58 -05:00

1128 líneas
125 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">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap">
<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}
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)}
.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}
.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="convertir" tabindex="0">Convertir a 10 bits</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 MP4 10 bits, unas 10 veces más ligeros.</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="btnAsistente">Asistente paso a paso</button><button class="lnk" id="btnDesconectar" title="Quitar esta carpeta de la lista; no borra nada del disco">Desconectar</button></div></div>
<div class="sig" id="siguiente"></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 nada se borra. <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></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 class="toolbar">
<button id="btnPlan" class="p">Calcular</button>
<button id="btnAplicar" class="ok" disabled>Aplicar</button><span class="por-que" id="btnAplicarPorQue">Calcula primero</span>
<span class="sep"></span>
<span id="planResumen" class="mut"></span>
<button class="lnk" id="btnMarcarAlta" disabled>Solo los seguros</button>
<button class="lnk" id="btnMarcarTodo" disabled>Todo</button>
<button class="lnk" id="btnCosmeticos" disabled title="Renombrados de mes o pieza que solo cambian el nombre">+ cosméticos</button>
<button class="lnk" id="btnDesmarcar" disabled>Nada</button>
<button class="lnk" id="btnArbol" disabled>Cómo quedaría</button>
<input type="text" id="filtroPlan" placeholder="filtrar…" style="min-width:140px">
</div>
<div id="planAvisos"></div>
<div class="box"><table id="tablaPlan"><thead><tr><th></th><th>Ahora está en</th><th></th><th>Pasa a (editable)</th><th>Por qué</th><th>Seguridad</th><th class="num">N</th><th class="num">Peso</th></tr></thead><tbody><tr><td colspan="8" class="mut">Pulsa «Calcular».</td></tr></tbody></table></div>
</div>
<div class="sub" id="s-nombrar">
<div class="toolbar"><input type="text" id="filtroVoc" placeholder="buscar carpeta…" style="min-width:220px"><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>proyectos de más de <input type="number" id="stockMeses" value="3" min="0" style="width:60px"> meses</label>
<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</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div>
</div>
</div>
</section>
<!-- ============ CONVERTIR ============ -->
<section id="t-convertir">
<div class="cab"><h2 class="v">Convertir a 10 bits</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">Los clips de proyectos ya entregados pasan a <b>MP4 H.265 de 10 bits</b>, unas 10 veces más ligeros. <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. Convertir va en segundo plano, un clip tras otro; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, 10 bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el MP4 queda en su lugar con el mismo nombre más «_10bit». Los BRAW van por DaVinci Resolve Studio (debe estar abierto); el resto por ffmpeg con la GPU. Nada se borra; se deshace desde Historial.</span></p>
<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">Copia a otro disco una versión <b>compacta</b> del cliente, con los clips ya convertidos en vez de los originales. <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:320px"></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(5,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 como un cambio, 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>
<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;
const antes = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="spin"></span>' + antes;
try { await fn(); }
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); }
finally { btn.disabled = false; btn.innerHTML = antes; }
}
// 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 && CARPETA && CARPETA.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 (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 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}`;
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')});
fin('asis', 'El asistente terminó.' + (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 && vistaActual() === 'convertir') 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; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
function limpiarTablas(){
$('#tablaPlan tbody').innerHTML = '<tr><td colspan="8" class="mut">Pulsa «Calcular».</td></tr>'; $('#planResumen').textContent = ''; $('#planAvisos').innerHTML = ''; 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 || {}; T.push({raiz: (E.cola.actual || {}).raiz, q: 'Convirtiendo', d: `${E.cola.actual ? (E.cola.actual.ruta || '').split(/[\\/]/).pop() + ' · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: E.cola.actual ? Math.round(E.cola.actual.pct || 0) : null, 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="Desconectar esta carpeta de la lista" aria-label="Desconectar">✕</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('Desconectar «' + r.cliente + '»', `<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 desconectada; ${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 desconectar «' + 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 desconectar';
}
});
}
$('#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));
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 === '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').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
}
function queSeHizo(lote){
if (!lote) return 'Cambio';
if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por MP4 10 bits';
if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo';
if (lote.startsWith('dup-')) return 'Juntó material repetido';
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 en 10 bits`, 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});
const asis = () => asistente(raizActual(), false);
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(); }); b('Asistente paso a paso', asis); }
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); b('Asistente paso a paso', asis); }
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 a 10 bits'; 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.bytes - a.bytes);
const sin = VOC.filter(t => !t.cubeta && !t.dentro_pieza).length;
$('#vocResumen').textContent = `${rows.length} nombres · ${sin} sin reconocer`;
const bd = $('#badgeNombrar'); bd.textContent = sin; bd.classList.toggle('cero', !sin);
$('#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>' : ''}</b><span class="r">${esc((t.rutas||[]).slice(0,2).join(' · '))}</span></span><select data-tok="${esc(t.token)}">${opcionesCubeta(t.cubeta)}</select><small>${t.n} carpeta${t.n > 1 ? 's' : ''} · ${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));
}
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 ----------------
function estadoPlan(){
const hay = !!PLAN;
['btnMarcarAlta','btnMarcarTodo','btnCosmeticos','btnDesmarcar','btnArbol'].forEach(id => $('#' + id).disabled = !hay);
if (!hay) { bloquear('btnAplicar', 'Calcula primero'); $('#btnAplicar').textContent = 'Aplicar'; return; }
const m = PLAN.filas.filter(r => r.marcado);
bloquear('btnAplicar', PLAN.filas.length ? (m.length ? '' : 'Marca algún movimiento') : 'No hay nada que mover');
$('#btnAplicar').textContent = m.length ? `Aplicar ${m.length} movimiento${m.length > 1 ? 's' : ''}` : 'Aplicar';
$('#planResumen').textContent = PLAN.filas.length ? `${m.length} de ${PLAN.filas.length} marcados · ${gb(m.reduce((s,r)=>s+r.bytes,0))} · ${num(m.reduce((s,r)=>s+r.n,0))} archivos` : '';
}
$('#btnPlan').onclick = () => accion($('#btnPlan'), async () => { $('#planResumen').innerHTML = '<span class="spin"></span> calculando…'; PLAN = await api('/api/plan?raiz=' + encodeURIComponent(raizActual())); ABIERTOS = new Set(); pintarPlan(); if (CARPETA) { CARPETA.plan = {filas: PLAN.filas.length, marcadas: PLAN.filas.filter(r => r.marcado).length}; pintarIndice(); } });
let ABIERTOS = new Set();
function pintarPlan(){
if (!PLAN) { estadoPlan(); return; }
const f = ($('#filtroPlan').value || '').toLowerCase();
$('#planAvisos').innerHTML = (PLAN.avisos||[]).map(a => `<div class="hint warn">${esc(a)}</div>`).join('');
const filas = PLAN.filas.filter(r => !f || (r.origen_rel + ' ' + r.destino_rel + ' ' + r.motivo).toLowerCase().includes(f));
const grupos = {}; filas.forEach(r => (grupos[r.cubeta] = grupos[r.cubeta] || []).push(r));
const ordenG = Object.keys(grupos).sort((a, b) => grupos[b].reduce((s,r)=>s+r.bytes,0) - grupos[a].reduce((s,r)=>s+r.bytes,0));
let html = '';
for (const g of ordenG) {
const rs = grupos[g].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).length, abierto = ABIERTOS.has(g) || !!f;
const dud = rs.filter(r => r.confianza === 'baja').length;
html += `<tr class="grp" data-g="${esc(g)}"><td colspan="8"><span class="caret">${abierto ? '▾' : '▸'}</span><span class="tag" style="border-color:${COLORES[g]||''};color:${COLORES[g]||''}">${esc(NOMBRE_CUBETA[g] || g)}</span> <b>${rs.length} movimiento${rs.length > 1 ? 's' : ''}</b><span class="cnt">${num(rs.reduce((s,r)=>s+r.n,0))} archivos · ${gb(rs.reduce((s,r)=>s+r.bytes,0))} · ${marc} marcado${marc !== 1 ? 's' : ''}${dud ? ` · <span class="bad">${dud} dudoso${dud > 1 ? 's' : ''}</span>` : ''}</span></td></tr>`;
if (abierto) for (const r of rs) html += `<tr data-id="${r.id}"><td><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"></td><td class="path">${esc(r.origen_rel)}${r.archivos ? `<div class="cell-small">${r.archivos.length} archivos: ${r.archivos.slice(0,3).map(a => esc(a.split(/[\\/]/).pop())).join(', ')}${r.archivos.length > 3 ? '…' : ''}</div>` : ''}</td><td class="arrow">→</td><td><input class="dest" type="text" value="${esc(r.destino_rel)}" aria-label="destino"><div class="cell-small">${esc(r.accion)}</div></td><td class="cell-small">${esc(r.motivo)}</td><td><span class="tag ${r.confianza}">${SEGURIDAD[r.confianza] || r.confianza}</span></td><td class="num">${r.n}</td><td class="num">${gb(r.bytes)}</td></tr>`;
}
$('#tablaPlan tbody').innerHTML = html || '<tr><td colspan="8" class="mut">Nada que mover: la carpeta ya está en orden.</td></tr>';
$$('#tablaPlan tr.grp').forEach(tr => tr.onclick = () => { const g = tr.dataset.g; if (ABIERTOS.has(g)) ABIERTOS.delete(g); else ABIERTOS.add(g); pintarPlan(); });
$$('#tablaPlan tr[data-id]').forEach(tr => { const r = PLAN.filas.find(x => x.id === +tr.dataset.id); tr.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; estadoPlan(); }; const inp = tr.querySelector('.dest'); inp.onchange = seguro(async () => { r.destino_rel = inp.value.trim(); r.editado = true; r.marcado = true; tr.querySelector('input[type=checkbox]').checked = true; if (!r.archivos) await api('/api/regla', {cliente: PLAN.cliente, origen: r.origen_rel, destino: r.destino_rel, cubeta: r.cubeta}); estadoPlan(); }); });
estadoPlan();
}
$('#filtroPlan').oninput = pintarPlan;
$('#btnMarcarAlta').onclick = () => { PLAN.filas.forEach(r => r.marcado = r.confianza === 'alta'); pintarPlan(); };
$('#btnMarcarTodo').onclick = () => { PLAN.filas.forEach(r => r.marcado = true); pintarPlan(); };
$('#btnCosmeticos').onclick = () => { PLAN.filas.forEach(r => { if (r.cosmetico) r.marcado = true; }); pintarPlan(); };
$('#btnDesmarcar').onclick = () => { PLAN.filas.forEach(r => r.marcado = false); pintarPlan(); };
$('#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); };
$('#btnAplicar').onclick = () => { const m = PLAN.filas.filter(r => r.marcado); modal('Aplicar los movimientos', `<p>Se van a mover <b>${m.length}</b> cosas (${gb(m.reduce((s,r)=>s+r.bytes,0))}, ${num(m.reduce((s,r)=>s+r.n,0))} archivos) dentro de <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra.</b> Es un cambio de sitio dentro del mismo disco: rápido y reversible desde Historial. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no se bloqueen archivos.</p>`, () => accion($('#btnAplicar'), 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; $('#tablaPlan tbody').innerHTML = ''; estadoPlan(); $('#planResumen').innerHTML = `<span class="ok">${res.movimientos} movimientos hechos</span>${res.errores.length ? ` · <span class="bad">${res.errores.length} avisos</span>` : ''}`; aviso(`Listo: ${res.movimientos} movimientos.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); if (res.errores.length) modal('Avisos al aplicar', `<pre>${esc(res.errores.join('\n'))}</pre>`, null); await cargarEstado(); cargarVista(); })); $('#modalSi').textContent = 'Mover'; };
// ---------------- revisar: stock viejo ----------------
$('#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=${+$('#stockMeses').value || 0}&tipos=${tipos}`);
STOCK = d.grupos.map(g => ({...g, sel: true, raiz: raizActual()}));
$('#stockResumen').textContent = `${d.n} archivos · ${gb(d.bytes)} en proyectos anteriores a ${d.tope}`;
$('#stockCaja').style.display = ''; pintarStock();
});
function pintarStock(){
$('#tablaStock tbody').innerHTML = STOCK.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('') || '<tr><td colspan="6" class="mut">Nada encontrado.</td></tr>';
$$('#tablaStock input').forEach(i => i.onchange = () => { STOCK[+i.dataset.i].sel = i.checked; resumenStock(); }); resumenStock();
}
function resumenStock(){ const s = STOCK.filter(g => g.sel); bloquear('btnStockPapelera', STOCK.length ? (s.length ? '' : 'Marca alguna carpeta') : 'Busca primero'); }
$('#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();
});
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}`;
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); modal('Convertir a 10 bits', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a MP4 H.265 10 bits, uno tras otro, en segundo plano.</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, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').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 o su salida ya existe (mismo clip en MP4 y MOV).`, ''); 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 = n('verificado'), 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 ? '−' + gb(hechosB) : `${verif}`, hechosB ? `ya liberados · ${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 ? Math.round((1-c.estimado/c.bytes)*100) : 0}%</span></div>`).join('');
}
if (ts.length) {
html += `<div class="cabsec">Conversiones<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' : 'ffmpeg · GPU'}</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 ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 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).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}`);
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})`]].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 || l.lote === 'optimizar' ? '<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(){
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 = pintarAjustes;
$('#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'); });
// ---------------- 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');
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;
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;
modal('Añadir carpeta de cliente', `<p class="path">${esc(ruta)}</p><label>Cliente <input type="text" id="nuevoCliente" value="${esc(sugerido)}" list="lcli2"></label><datalist id="lcli2">${clientes.map(c => `<option value="${esc(c)}">`).join('')}</datalist> <label>Tipo <select id="nuevoTipo">${Object.entries(ESTADO.tipos).map(([k,v]) => `<option value="${k}">${k} · ${esc(v)}</option>`).join('')}</select></label>`, 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');
}));
}
// ---------------- 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'};
$('#btnAsistente').onclick = () => asistente(raizActual(), false);
// ---------------- 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>