Primer commit del proyecto. Incluye el rediseño 1.6 (riel de pasos en la barra lateral), el actualizador con reintentos y respaldo, publicar.sh y la nueva opción de desconectar carpetas (1.6.1). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1076 líneas
118 KiB
HTML
1076 líneas
118 KiB
HTML
<!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 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>
|
||
<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">o <u>pega la ruta de la carpeta</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>
|
||
</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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})`); }
|
||
if (!r.ok || (j && j.error)) throw new ApiError((j && j.error) || `Error ${r.status}`);
|
||
return j;
|
||
}
|
||
function aviso(texto, tipo, enlace){
|
||
const d = document.createElement('div'); d.className = 'aviso ' + (tipo || '');
|
||
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'); }
|
||
finally { btn.disabled = false; btn.innerHTML = antes; }
|
||
}
|
||
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'); } };
|
||
$$('.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;
|
||
}
|
||
function pintarTareas(){
|
||
const T = tareasDe(ESTADO);
|
||
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} %</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 () => {
|
||
try {
|
||
const era = raizActual() === clave;
|
||
await api('/api/raiz/quitar', {raiz: clave, datos: $('#mDatos').checked});
|
||
aviso('Carpeta desconectada.', 'ok');
|
||
await cargarEstado();
|
||
if (era) { const s = $('#raiz'); s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); }
|
||
else pintarIndice();
|
||
} catch (e) { aviso(e.message, 'bad'); }
|
||
});
|
||
}
|
||
$('#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';
|
||
P.push(!esc ? {e:'bloq', t:'Convertir', d:'Después de escanear'} : !cv.camara_bytes ? {e:'hecho', t:'Convertir', d:'No hay material de cámara', corto: 'nada'}
|
||
: 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`}
|
||
: 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 && !activos ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya en 10 bits`, corto: `${reemp} hechos`}
|
||
: activos ? {e:'listo', t:'Convertir', d:`${activos} en cola, parada`, corto: 'parada'}
|
||
: {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';
|
||
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>` : 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(); });
|
||
|
||
// ---------------- 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'});
|
||
$('#historial').innerHTML = 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));
|
||
}
|
||
$('#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));
|
||
}));
|
||
zona.onclick = () => modal('Añadir carpeta de cliente', '<p>Pega la ruta completa de la carpeta (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">', () => proponerRaiz($('#rutaPegada').value.trim().replace(/^"|"$/g, '')));
|
||
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>
|