Las grabaciones van siempre a su día de rodaje y dentro a una carpeta por fuente; el DJI Mic, la Tascam y las Zoom se reconocen por su nombre y van a DJI_MIC, TASCAM y ZOOM en vez de caer juntas en AUDIO. El plan repasa lo ya ordenado sin carpeta de día o con la grabadora en AUDIO. «Mapa» pasa a «Organizar carpetas»: el árbol salía en fila por una regla CSS del ejemplo de tipo de trabajo; ahora va en vertical a toda la altura y a la derecha, fijos, «Qué va en esta carpeta» y «¿Dónde va a parar este archivo?». Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2437 líneas
271 KiB
HTML
2437 líneas
271 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">
|
||
<style>@font-face{font-family:Manrope;font-style:normal;font-weight:400 700;font-display:swap;src:url(/static/manrope.woff2) format('woff2')}</style>
|
||
<style>
|
||
:root{--bg:#ffffff;--panel:#ffffff;--panel2:#f3f4f5;--line:#e6e7e8;--line2:#f0f1f2;--tx:#17191c;--mut:#6d7076;--acc:#1f57d6;--acc2:#e8eefc;--ok:#2f8f5b;--ok2:#e6f2ea;--warn:#9a6d12;--warn2:#fbf1d9;--bad:#b3473e;--bad2:#f7e6e4}
|
||
*{box-sizing:border-box}
|
||
html{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}
|
||
/* dentro de la ventana del programa, lo que es marco (menú, botones, títulos) no se selecciona ni se arrastra como en una página */
|
||
.nativo aside,.nativo nav,.nativo #tareas,.nativo button,.nativo .tabs,.nativo h1,.nativo h2,.nativo h3,.nativo label{-webkit-user-select:none;user-select:none}
|
||
.nativo img,.nativo a,.nativo svg{-webkit-user-drag:none}
|
||
/* ── 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}
|
||
nav a.eng2{font-size:12.5px;font-weight:500}
|
||
/* ── manual ── */
|
||
.manual{max-width:1120px}
|
||
.manual h3{margin:24px 0 8px;font-size:14px;padding-bottom:6px;border-bottom:1px solid var(--line)}
|
||
.manual dl{display:grid;grid-template-columns:230px 1fr;gap:10px 22px;margin:0}
|
||
.manual dt{font-weight:600}
|
||
.manual dd{margin:0}
|
||
.manual dd i{font-style:normal;color:var(--mut)}
|
||
@media (max-width:900px){.manual dl{grid-template-columns:1fr;gap:2px 0}.manual dd{margin-bottom:10px}}
|
||
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}
|
||
.portada{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}
|
||
.portada .pc{display:flex;flex-direction:column;gap:3px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;cursor:pointer;background:var(--panel);font-size:12.5px}
|
||
.portada .pc:hover{border-color:var(--acc);background:var(--acc2)}
|
||
.portada .pc b{font-size:14px}
|
||
.vacio-pasos b{display:block;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);margin-bottom:4px}
|
||
@media(max-width:900px){.vacio-pasos{grid-template-columns:1fr 1fr}}
|
||
.peso{display:flex;height:14px;margin:4px 0 8px;border-radius:4px;overflow:hidden;gap:1px;background:var(--panel2)}
|
||
.peso i{display:block;height:100%;min-width:2px;cursor:default}
|
||
.peso-linea{font-size:12.5px;color:var(--mut)}
|
||
.peso-linea b{color:var(--tx);font-variant-numeric:tabular-nums}
|
||
/* bloques genéricos */
|
||
.cards{display:grid;grid-template-columns:repeat(8,1fr);gap:10px;margin-bottom:14px}
|
||
.cards.c4{grid-template-columns:repeat(4,1fr)}
|
||
@media(max-width:1150px){.cards{grid-template-columns:repeat(4,1fr)}}
|
||
.card{border-bottom:1px solid var(--line);padding:4px 0 8px}
|
||
.card b{display:block;font-size:19px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
|
||
.card small{color:var(--mut);font-size:11.5px}
|
||
.row{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:18px}
|
||
.row>*{min-width:0}
|
||
@media(max-width:1000px){.row{grid-template-columns:1fr}}
|
||
.box{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:14px;overflow-x:auto}
|
||
.box h3{margin:0 0 8px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);font-weight:600}
|
||
.box.plano{border:0;padding:0;overflow:visible}
|
||
table{width:100%;border-collapse:collapse;font-size:12.5px}
|
||
th,td{padding:6px 8px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
|
||
th{color:var(--mut);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.06em}
|
||
td.num,th.num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||
.mut{color:var(--mut)}.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
|
||
.tag{display:inline-block;padding:1px 7px;border-radius:99px;font-size:11px;border:1px solid var(--line);color:var(--mut);white-space:nowrap}
|
||
.tag.alta{color:var(--ok);border-color:var(--ok)}.tag.media{color:var(--warn);border-color:var(--warn)}.tag.baja{color:var(--bad);border-color:var(--bad)}
|
||
.bar{height:7px;background:var(--panel2);border-radius:99px;overflow:hidden}.bar i{display:block;height:100%;background:var(--acc)}
|
||
.tm{position:relative;height:300px;border-radius:6px;overflow:hidden;background:var(--panel2)}
|
||
.tm div{position:absolute;overflow:hidden;padding:4px 6px;font-size:11px;border:1px solid rgba(255,255,255,.6);color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5);cursor:default}
|
||
.tm div b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
|
||
.toolbar label{color:var(--mut);font-size:12px;display:flex;align-items:center;gap:6px}
|
||
.toolbar .sep{flex:1}
|
||
.path{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;word-break:break-all}
|
||
.arrow{color:var(--mut)}
|
||
.dest{width:100%;font-family:ui-monospace,Consolas,monospace;font-size:11.5px}
|
||
tr.grp td{background:var(--panel2);cursor:pointer;font-size:12.5px;padding:8px}
|
||
tr.grp td b{color:var(--tx)}
|
||
tr.grp .caret{display:inline-block;width:14px;color:var(--mut)}
|
||
tr.grp .cnt{color:var(--mut);margin-left:8px}
|
||
.modal{position:fixed;inset:0;background:rgba(23,25,28,.45);display:none;align-items:center;justify-content:center;z-index:20}
|
||
.modal.on{display:flex}
|
||
.modal .box{max-width:640px;width:92%;max-height:90vh;overflow:auto;box-shadow:0 20px 60px rgba(0,0,0,.18)}
|
||
.modal h3{font-size:15px;text-transform:none;letter-spacing:0;color:var(--tx);font-weight:700}
|
||
.preg{margin:14px 0 0}
|
||
.preg>label{display:block;font-weight:600;margin-bottom:5px}
|
||
.preg>span{display:block;color:var(--mut);font-size:12px;margin-top:4px}
|
||
.ejtipo{display:grid;gap:5px;margin:6px 0 0;padding:10px 12px;background:var(--panel2);border-radius:8px}
|
||
.ejtipo div{display:flex;gap:4px 10px;align-items:baseline;flex-wrap:wrap}
|
||
.ejtipo b{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;font-weight:600;word-break:break-all}
|
||
.ejtipo span{font-size:12px;color:var(--mut)}
|
||
.tipos{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:6px}
|
||
.tipo{display:block;position:relative;padding:9px 12px 9px 34px;border:1px solid var(--line);border-radius:8px;cursor:pointer;background:var(--panel)}
|
||
.tipo:hover{border-color:var(--acc)}
|
||
.tipo:has(input:checked){border-color:var(--acc);background:var(--acc2)}
|
||
.tipo:has(input:focus-visible){outline:2px solid var(--acc);outline-offset:1px}
|
||
.tipo input{position:absolute;left:12px;top:12px;margin:0}
|
||
.tipo b{display:block;font-size:13px}
|
||
.tipo span{display:block;font-size:12px;color:var(--mut);margin-top:1px}
|
||
.tipo em{display:block;font-size:11.5px;color:var(--mut);font-style:normal;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.arbol{margin:6px 0 0;padding:10px 12px;background:var(--panel2);border-radius:8px;font-family:ui-monospace,Consolas,monospace;font-size:11.5px;line-height:1.6}
|
||
.arbol .ln{display:flex;gap:10px;align-items:baseline;white-space:pre}
|
||
.arbol .ln b{font-weight:600}
|
||
.arbol .ln span{font:12px/1.4 Manrope,system-ui,sans-serif;color:var(--mut);white-space:normal}
|
||
.mapa2{grid-template-columns:minmax(0,1.3fr) minmax(300px,1fr);align-items:start}
|
||
.mapa2 .lado{position:sticky;top:10px;display:grid;gap:14px}
|
||
#mapaArbol{max-height:calc(100vh - 200px);overflow:auto;font-size:12.5px}
|
||
#mapaArbol .nd{padding:4px 8px}
|
||
@media(max-width:1000px){.mapa2 .lado{position:static}#mapaArbol{max-height:none}}
|
||
.arbol{font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.55}
|
||
.arbol .nd{display:flex;gap:10px;align-items:baseline;padding:3px 7px;border-radius:5px;cursor:pointer}
|
||
.arbol .nd:hover{background:var(--panel2)}
|
||
.arbol .nd.sel{background:var(--acc2);color:var(--acc)}
|
||
.arbol .nd .nm{font-weight:600}
|
||
.arbol .nd.var .nm{font-style:italic;font-weight:500}
|
||
.arbol .leyenda{margin-top:10px;font-family:Manrope,system-ui,sans-serif;font-size:11.5px;color:var(--mut)}
|
||
.arbol .nd .cif{margin-left:auto;font-family:Manrope,system-ui,sans-serif;font-size:11px;color:var(--mut);white-space:nowrap}
|
||
.arbol .hijos{margin-left:9px;border-left:1px solid var(--line);padding-left:11px}
|
||
.arbol .vacio{color:var(--mut);font-weight:400}
|
||
.arbol .pt{width:9px;height:9px;border-radius:3px;flex:none;background:var(--line)}
|
||
.arbol .nd.hit{background:var(--ok2);outline:1px solid var(--ok)}
|
||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0}
|
||
.chips button{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;padding:3px 8px}
|
||
.resp{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:10px 12px;border-radius:8px;background:var(--panel2)}
|
||
.resp .pt{width:12px;height:12px;border-radius:4px;flex:none;margin-top:3px}
|
||
.resp .d{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all;font-weight:600}
|
||
.leyenda-col{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:10px;font-size:12px;color:var(--mut)}
|
||
.leyenda-col span{display:flex;align-items:center;gap:6px}
|
||
.leyenda-col i{width:10px;height:10px;border-radius:3px;display:inline-block}
|
||
.det h4{margin:0 0 4px;font-size:13.5px}
|
||
.det .ruta{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;word-break:break-all;color:var(--mut);margin-bottom:10px}
|
||
.det ul{margin:4px 0 0;padding-left:18px;font-size:12.5px}
|
||
.det .vars{display:grid;gap:3px;margin-top:6px;font-size:11.5px;color:var(--mut)}
|
||
.det .vars code{font-family:ui-monospace,Consolas,monospace;color:var(--tx)}
|
||
.sinsitio{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--line2)}
|
||
pre{background:var(--panel2);padding:10px;border-radius:6px;overflow:auto;font-size:11.5px;max-height:280px}
|
||
.tree{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;white-space:pre;line-height:1.5;overflow:auto;max-height:420px}
|
||
.hint{background:var(--acc2);border:0;border-left:3px solid var(--acc);border-radius:0 6px 6px 0;padding:9px 12px;color:var(--tx);font-size:12.5px;margin-bottom:14px}
|
||
.hint.warn{background:var(--warn2);border-left-color:var(--warn)}
|
||
.cell-small{font-size:11px;color:var(--mut)}
|
||
.chip{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-radius:5px;padding:1px 6px;margin:2px 3px 0 0;font-size:11px}
|
||
.legend span{display:inline-flex;align-items:center;gap:5px;margin-right:12px;font-size:11.5px;color:var(--mut)}
|
||
.legend i{width:10px;height:10px;border-radius:2px;display:inline-block}
|
||
.spin{display:inline-block;width:12px;height:12px;border:2px solid var(--line);border-top-color:var(--acc);border-radius:50%;animation:sp 1s linear infinite;vertical-align:-2px}
|
||
@keyframes sp{to{transform:rotate(360deg)}}
|
||
.ejemplos li{font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--mut)}
|
||
.barra{height:7px;background:var(--panel2);border-radius:4px;overflow:hidden;width:200px;display:inline-block;vertical-align:middle}
|
||
.barra i{display:block;height:100%;background:var(--acc);transition:width .3s;min-width:2px}
|
||
.barra.ind i{width:30%!important;animation:ind 1.2s infinite linear}
|
||
@keyframes ind{from{margin-left:-30%}to{margin-left:100%}}
|
||
.asis-diag{display:grid;grid-template-columns:auto auto 1fr;gap:4px 12px;font-size:12.5px;margin:8px 0}
|
||
.asis-diag b{text-align:right;font-variant-numeric:tabular-nums}
|
||
.asis-paso{border:1px solid var(--line);border-radius:6px;padding:9px 12px;margin:8px 0}
|
||
.asis-paso h4{margin:0 0 4px;font-size:13.5px}
|
||
.asis-paso label{display:block;margin:4px 0;cursor:pointer}
|
||
.asis-frase{color:var(--acc)}
|
||
.colabar{display:flex;gap:12px;align-items:center;padding:10px 12px;background:var(--acc2);border-radius:6px;margin-bottom:10px;font-size:13px}
|
||
.colabar .b{flex:1;height:8px;background:#fff;border-radius:99px;overflow:hidden}
|
||
.colabar .b i{display:block;height:100%;background:var(--acc)}
|
||
.colabar b{white-space:nowrap}
|
||
.est{display:inline-block;padding:1px 8px;border-radius:99px;font-size:11px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
|
||
.est.pendiente,.est.cancelado,.est.descartado{background:var(--panel2);color:var(--mut)}
|
||
.est.corriendo{background:var(--acc2);color:var(--acc)}
|
||
.est.listo,.est.verificado,.est.reemplazado{background:var(--ok2);color:var(--ok)}
|
||
.est.error{background:var(--bad2);color:var(--bad)}
|
||
.est.candidato{background:#fff;color:var(--mut);border:1px solid var(--line)}
|
||
.actualizacion{border-color:var(--acc)!important;background:var(--acc2)!important;margin-bottom:18px}
|
||
/* nombrar */
|
||
.nombrar{list-style:none;margin:0;padding:0}
|
||
.acc{display:flex;flex-direction:column;gap:8px;margin-bottom:18px}
|
||
.ac{display:grid;grid-template-columns:26px minmax(0,1fr) auto;gap:14px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px}
|
||
.ac.hecho{background:transparent;border-style:dashed;opacity:.62}
|
||
.ac.espera{opacity:.72}
|
||
.ac .ico{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;flex:none;background:var(--acc2);color:var(--acc)}
|
||
.ac.hecho .ico{background:var(--ok2);color:var(--ok)}
|
||
.ac.espera .ico{background:var(--panel2);color:var(--mut)}
|
||
.ac .tx{min-width:0}
|
||
.ac .tx b{font-size:14px;display:block}
|
||
.ac .tx .q{font-size:12.5px;color:var(--mut);margin-top:1px}
|
||
.ac .tx .d{font-size:12px;color:var(--mut);margin-top:3px}
|
||
.ac .der{display:flex;flex-direction:column;align-items:flex-end;gap:5px;flex:none}
|
||
.ac .cifra{font-size:13px;font-weight:700;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||
.ac.hecho .cifra{font-weight:500;color:var(--mut)}
|
||
.ac button{white-space:nowrap}
|
||
@media (max-width:820px){ .ac{grid-template-columns:26px minmax(0,1fr)} .ac .der{grid-column:2;align-items:flex-start} }
|
||
#planIntro b.grande{font-size:14px}
|
||
/* plan: fichas de los tres pasos, el ejemplo animado, la lista y la barra de avance */
|
||
.fichas{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0 0 14px}
|
||
@media(max-width:900px){.fichas{grid-template-columns:1fr}}
|
||
.ficha{display:grid;grid-template-columns:26px minmax(0,1fr);gap:10px;align-items:start;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px 12px;cursor:pointer;position:relative}
|
||
.ficha:hover{border-color:var(--acc)}
|
||
.ficha.on{border-color:var(--acc);box-shadow:inset 0 0 0 1px var(--acc);background:var(--acc2)}
|
||
.ficha.vacio{opacity:.7;cursor:default}
|
||
.ficha .num{width:26px;height:26px;border-radius:50%;background:var(--acc2);color:var(--acc);font-weight:700;display:flex;align-items:center;justify-content:center;font-size:13px}
|
||
.ficha.on .num{background:var(--acc);color:#fff}
|
||
.ficha.vacio .num{background:var(--ok2);color:var(--ok)}
|
||
.ficha .tx{min-width:0}
|
||
.ficha .tx b{font-size:14px;display:block}
|
||
.ficha .tx .q{font-size:12px;color:var(--mut);margin-top:1px}
|
||
.ficha .cifra{font-size:12.5px;margin-top:6px}
|
||
.ficha .cifra b{font-size:15px}
|
||
.ficha .lnk{position:absolute;right:6px;bottom:6px;font-size:12px;padding:3px 6px}
|
||
.ejemplo{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;margin-bottom:14px;overflow:hidden}
|
||
.ejcab{display:flex;gap:8px;align-items:center;margin-bottom:8px}
|
||
.ejcols{display:grid;grid-template-columns:minmax(0,1fr) 40px minmax(0,1fr);gap:8px;align-items:start}
|
||
@media(max-width:820px){.ejcols{grid-template-columns:1fr}.ejflecha{display:none}}
|
||
.ejtit{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);font-weight:700;margin-bottom:6px}
|
||
.ejarbol{background:var(--panel2);border-radius:8px;padding:10px 12px;min-height:96px;font-size:12.5px}
|
||
.ejarbol .nivel{display:flex;align-items:center;gap:6px;padding:3px 0 3px calc(var(--n,0) * 18px);position:relative;min-width:0}
|
||
.ejarbol .nivel>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.ejarbol .nivel:not(.raiz)::before{content:'';position:absolute;left:calc(var(--n,0) * 18px - 11px);top:-5px;width:8px;height:16px;border-left:1px solid #c7cbd1;border-bottom:1px solid #c7cbd1;border-radius:0 0 0 4px}
|
||
.ejarbol .raiz{font-weight:700}
|
||
.fo{display:inline-block;width:14px;height:11px;flex:none;background:#e9b949;border-radius:2px;position:relative;margin-top:2px}
|
||
.fo::before{content:'';position:absolute;left:0;top:-3px;width:6px;height:4px;background:#e9b949;border-radius:2px 2px 0 0}
|
||
.ejarbol .mv{display:inline-flex;align-items:center;gap:6px;max-width:100%;padding:2px 8px;border-radius:6px;background:#fff;border:1px solid var(--acc);color:var(--tx);font-weight:600;box-shadow:0 1px 3px rgba(0,0,0,.08)}
|
||
.ejarbol .mv.chip{background:var(--acc);color:#fff}
|
||
.ejarbol .mv.fantasma{opacity:.3;border-style:dashed;box-shadow:none}
|
||
.ejarbol .mv.llega{animation:llega .5s ease-out}
|
||
@keyframes llega{0%{transform:scale(1.15);box-shadow:0 0 0 6px var(--acc2)}100%{transform:scale(1);box-shadow:0 1px 3px rgba(0,0,0,.08)}}
|
||
.vuelo{z-index:3;pointer-events:none;will-change:transform}
|
||
.ejflecha{text-align:center;color:var(--acc);font-size:22px;padding-top:44px}
|
||
.ejtexto{margin-top:10px;font-size:13px;opacity:0;transform:translateY(4px);transition:opacity .4s,transform .4s}
|
||
.ejtexto.visto{opacity:1;transform:none}
|
||
.ejtexto .pq{color:var(--mut);font-size:12.5px;margin-top:2px}
|
||
.ejpie{font-size:12.5px;margin-top:8px}
|
||
.lista{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 14px 10px;margin-bottom:14px}
|
||
.lista .lcab{display:flex;gap:10px;align-items:center;padding:8px 0;font-size:13px;flex-wrap:wrap}
|
||
.lista .grp{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);padding:10px 0 4px;font-weight:700}
|
||
.lista .fila{display:grid;grid-template-columns:22px minmax(0,1.1fr) 18px minmax(0,1.3fr) auto auto;gap:2px 10px;align-items:center;padding:6px 0;border-bottom:1px solid var(--line2);font-size:13px}
|
||
.lista .fila:last-child{border-bottom:0}
|
||
.lista .fila .de{min-width:0}
|
||
.lista .fila .de b{display:block;overflow-wrap:anywhere}
|
||
.lista .fila .en{display:block;font-size:11.5px;color:var(--mut);font-family:ui-monospace,Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.lista .fila .fl{color:var(--acc);text-align:center}
|
||
.lista .fila .a{font-family:ui-monospace,Consolas,monospace;font-size:12px;overflow-wrap:anywhere;min-width:0}
|
||
.lista .fila .a b{font-family:Manrope,system-ui,sans-serif;font-size:13px}
|
||
.lista .fila .meta{font-size:12px;color:var(--mut);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||
.lista .fila .acts{white-space:nowrap;display:flex;gap:4px;align-items:center;justify-content:flex-end;min-width:150px}
|
||
.lista .fila .ed{border:0;background:none;color:var(--acc);cursor:pointer;font-size:12px;padding:0 4px;opacity:0}
|
||
.lista .fila:hover .ed,.lista .fila:focus-within .ed{opacity:1}
|
||
.lista .fila .pq{grid-column:2/7;display:none;font-size:12px;color:var(--mut)}
|
||
.lista.porque .fila .pq{display:block}
|
||
.lista .fila.sinmarcar{opacity:.65}
|
||
.lista .fila.esej .de b::after{content:'ejemplo';font-size:10px;color:var(--acc);font-weight:700;letter-spacing:.06em;text-transform:uppercase;margin-left:6px;border:1px solid var(--acc);border-radius:99px;padding:0 6px}
|
||
.lista .opc{margin-top:10px;border-top:1px dashed var(--line);padding-top:4px}
|
||
@media(max-width:900px){.lista .fila{grid-template-columns:22px minmax(0,1fr) auto}.lista .fila .fl{display:none}.lista .fila .a,.lista .fila .meta,.lista .fila .acts{grid-column:2}.lista .fila .acts{justify-content:flex-start}.lista .fila .pq{grid-column:2/4}}
|
||
details.avisos summary{cursor:pointer;list-style:none}
|
||
details.avisos summary::before{content:'▸';display:inline-block;width:14px;color:var(--warn);transition:transform .15s}
|
||
details.avisos[open] summary::before{transform:rotate(90deg)}
|
||
details.avisos ul{margin:6px 0 0;padding-left:22px}
|
||
.avance{background:var(--panel);border:1px solid var(--acc);border-radius:8px;padding:12px 14px;margin-bottom:14px}
|
||
.avance.listo{border-color:var(--ok)}
|
||
.avance.listo.conerr{border-color:var(--warn)}
|
||
.avance .l1{display:flex;gap:8px;align-items:center;font-size:13.5px;flex-wrap:wrap}
|
||
.avance .ico{width:22px;height:22px;border-radius:50%;background:var(--ok2);color:var(--ok);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:13px}
|
||
.avance.conerr .ico{background:var(--warn2);color:var(--warn)}
|
||
.avance .bar.grande{height:10px;margin:8px 0 6px}
|
||
.avance .bar i{transition:width .35s}
|
||
.avance.listo .bar i{background:var(--ok)}
|
||
.avance .l3{font-size:12.5px}
|
||
.nombrar li{grid-template-columns:minmax(0,1fr) auto 110px}
|
||
.nombrar li .r{display:block}
|
||
.nombrar li .sg{display:block;font-size:12px;color:var(--acc);margin-top:3px;white-space:normal}
|
||
.nombrar li .sg b{display:inline}
|
||
.nombrar li .nt{display:block;font-size:12px;color:var(--mut);margin-top:3px;white-space:normal}
|
||
.nombrar li .acts button{white-space:nowrap}
|
||
.nombrar li .acts{display:flex;gap:6px;align-items:center;justify-content:flex-end}
|
||
.nombrar li .acts select{max-width:230px}
|
||
.stk-grp td{background:var(--panel2);font-weight:600;font-size:12.5px}
|
||
.nombrar li{display:grid;grid-template-columns:minmax(0,1fr) 260px 130px;gap:10px;align-items:center;padding:7px 0;border-bottom:1px solid var(--line);font-size:13px}
|
||
.nombrar li b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.nombrar li small{color:var(--mut);white-space:nowrap}
|
||
.nombrar li .r{font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
details.mas>summary{cursor:pointer;color:var(--mut);font-size:12.5px;margin:8px 0}
|
||
/* convertir */
|
||
.cfila{display:grid;grid-template-columns:24px 110px minmax(0,1fr) 150px 70px 110px;gap:10px;padding:7px 0;border-bottom:1px solid var(--line2);font-size:12.5px;align-items:center}
|
||
.cfila .acts{text-align:right;white-space:nowrap}
|
||
.cfila .acts button{background:none;border:0;color:var(--acc);font-size:12px;font-weight:600;padding:0 4px;cursor:pointer}
|
||
.cfila .nom .sub2 .carp{font-family:ui-monospace,Consolas,monospace}
|
||
.cgrupo{display:flex;gap:10px;align-items:center;padding:12px 0 6px;font-size:12.5px;border-top:1px solid var(--line);margin-top:6px}
|
||
.cgrupo.primero{border-top:0;margin-top:0}
|
||
.cgrupo .pl{background:none;border:0;padding:0;color:var(--mut);width:14px;cursor:pointer;display:inline-flex}
|
||
.cgrupo .cab{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;color:var(--mut);white-space:nowrap}
|
||
.cgrupo .cab.corre{color:var(--acc)}
|
||
.excl{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:12px 0 4px;border-top:1px dashed var(--line);margin-top:6px;font-size:12.5px;color:var(--mut)}
|
||
.excl button{background:var(--panel2);border:1px solid var(--line);border-radius:99px;padding:1px 9px;font-size:12px;color:var(--tx);cursor:pointer}
|
||
.excl button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)}
|
||
.excl button.ojo{background:var(--warn2);color:var(--warn)}
|
||
.momento{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:8px}
|
||
.momento.corre{border-color:var(--acc)} .momento.listo{border-color:var(--ok)}
|
||
.momento .l1{display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13.5px}
|
||
.momento .l3{font-size:12.5px}
|
||
.momento .bar.grande{height:10px}
|
||
.momento button.grande{font-size:14px;padding:9px 16px;border-radius:6px;font-weight:700}
|
||
.momento .ico{width:22px;height:22px;border-radius:50%;background:var(--ok2);color:var(--ok);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:13px}
|
||
.antes{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:6px}
|
||
.antes .l1,.antes .l3{display:flex;gap:16px;align-items:center;flex-wrap:wrap;font-size:12.5px}
|
||
.antes .l2{display:flex;gap:12px;align-items:center}
|
||
.antes .l2 b{font-size:22px;white-space:nowrap}.antes .l2 b.acc{color:var(--acc)}
|
||
.antes .pista{flex:1;height:18px;background:var(--panel2);border-radius:99px;overflow:hidden;display:flex}
|
||
.antes .pista i.conv{display:block;height:100%;background:var(--acc);border-radius:99px 0 0 99px}
|
||
.antes .pista i.libre{display:block;height:100%;background:var(--ok2);border-right:2px solid var(--ok)}
|
||
.antes .sw{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:-1px;margin-right:4px}.antes .sw.conv{background:var(--acc)}.antes .sw.libre{background:var(--ok2);border:1px solid var(--ok)}
|
||
.modal.ancho .box{max-width:1200px}
|
||
.ajcola{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:10px}
|
||
@media(max-width:900px){.ajcola{grid-template-columns:1fr}}
|
||
.ajcola .caja{border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:8px;background:var(--panel)}
|
||
.ajcola .caja .t{display:flex;gap:8px;align-items:center;font-weight:700;font-size:14px}
|
||
.ajcola .caja .d{font-size:12.5px;color:var(--mut)}
|
||
.ajcola .caja .f{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:var(--mut)}
|
||
.ajcola input[type=time],.ajcola input[type=number]{font:inherit;padding:4px 6px;border:1px solid var(--line);border-radius:5px}
|
||
.ajcola .dia{display:flex;gap:2px;height:12px;border-radius:4px;overflow:hidden}
|
||
.ajcola .dia i{display:block;height:100%}
|
||
.ajcola .apps span{background:var(--acc2);color:var(--acc);border-radius:99px;padding:1px 9px;font-weight:600;font-size:12px;margin-right:4px}
|
||
.tanda{background:var(--panel);border:1px solid var(--ok);border-radius:8px;padding:14px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:12px}
|
||
.tanda .l1{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:13.5px}
|
||
.tanda .cifras{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px}
|
||
@media(max-width:900px){.tanda .cifras{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||
.tanda .cifras div{border-bottom:1px solid var(--line);padding:2px 0 8px}
|
||
.tanda .cifras b{font-size:20px;display:block;white-space:nowrap}
|
||
.tanda .cifras small{font-size:12px;color:var(--mut)}
|
||
.tanda .carp{display:flex;gap:10px;align-items:center;font-size:12.5px}
|
||
.tanda .carp .n{width:260px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.tanda .carp .b{flex:1;height:8px;background:var(--panel2);border-radius:99px;overflow:hidden}.tanda .carp .b i{display:block;height:100%;background:var(--acc)}
|
||
.tanda .carp .c{width:190px;text-align:right;color:var(--mut);white-space:nowrap}
|
||
.marcar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px 0;border-bottom:1px solid var(--line);font-size:12.5px}
|
||
.marcar>span{color:var(--mut);margin-right:2px}
|
||
.marcar button{background:#fff;border:1px solid var(--line);border-radius:99px;padding:2px 10px;font-size:12px;font-weight:600;color:var(--tx);cursor:pointer}
|
||
.marcar button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)}
|
||
.marcar input[type=search]{font:inherit;padding:4px 8px;border:1px solid var(--line);border-radius:5px;min-width:220px;margin-left:auto}
|
||
.cmp{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||
.cmp .lado{display:flex;flex-direction:column;gap:6px;min-width:0}
|
||
.cmp .marco{position:relative;background:#d9dcdf;border-radius:8px;overflow:hidden;aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#4d5157;font-size:12.5px}
|
||
.cmp .marco img{width:100%;height:100%;object-fit:contain;display:block;cursor:crosshair}
|
||
.cmp .lupa{position:absolute;width:220px;height:220px;border:2px solid var(--acc);border-radius:8px;background-repeat:no-repeat;background-color:#000;pointer-events:none;display:none;box-shadow:0 4px 14px rgba(0,0,0,.3)}
|
||
.cmp .marco.con .lupa{display:block}
|
||
.cmpnota{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:12px}
|
||
.cmpnota>div{border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-size:12.5px;display:flex;flex-direction:column;gap:4px}
|
||
.cmpnota b.n{font-size:26px;color:var(--ok)} .cmpnota b.n.baja{color:var(--warn)}
|
||
.cmpt{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:var(--mut);margin-top:10px}
|
||
.cmpt button{font-size:12px;padding:2px 8px}
|
||
.cmpt button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)}
|
||
.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 .nom .sub2.err{white-space:normal}
|
||
.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}
|
||
.cfila.activa{background:var(--acc2);margin:0 -14px;padding-left:14px;padding-right:14px;border-radius:6px;border-bottom:0}
|
||
.cfila .est.prog{display:block;text-align:center;padding:3px 0;border-radius:5px;background:linear-gradient(90deg,var(--acc) var(--p,0%),#fff var(--p,0%));border:1px solid var(--acc);color:var(--acc);font-variant-numeric:tabular-nums}
|
||
.cfila .est.prog.media{color:#fff;text-shadow:0 0 3px rgba(0,0,0,.4)}
|
||
.cfila .nom .sub2 .acc{color:var(--acc);font-weight:600}
|
||
.convbar{display:flex;gap:2px;align-items:center;flex-wrap:wrap;padding:6px 0 8px;border-bottom:1px solid var(--line);margin-bottom:4px;font-size:12px}
|
||
.convbar .mut{margin:0 4px 0 10px}
|
||
.convbar .lnk{padding:2px 6px;font-size:12px}
|
||
.convbar .lnk.on{background:var(--acc2);border-radius:4px;text-decoration:none}
|
||
.cabsec{display:flex;gap:10px;align-items:center;margin:14px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);font-weight:600}
|
||
.cabsec .sep{flex:1;height:1px;background:var(--line)}
|
||
details.ajc{display:inline}
|
||
details.ajc>summary{display:inline;cursor:pointer;color:var(--acc);list-style:none}
|
||
details.ajc>summary::-webkit-details-marker{display:none}
|
||
details.ajc .toolbar{margin:10px 0 0;padding:10px 12px;background:var(--panel2);border-radius:6px}
|
||
/* historial */
|
||
.dia{font-weight:700;font-size:13px;margin:14px 0 2px}
|
||
.ev{display:grid;grid-template-columns:52px 1fr auto;gap:12px;padding:7px 0;border-bottom:1px solid var(--line2);align-items:start;font-size:13px}
|
||
.ev .h{color:var(--mut);font-variant-numeric:tabular-nums}
|
||
.ev .q b{font-weight:600}
|
||
.ev .q .cell-small{margin-top:2px}
|
||
.ev details summary{cursor:pointer;color:var(--mut);font-size:11.5px;margin-top:2px}
|
||
.ev button{padding:3px 9px;font-size:12px}
|
||
/* ajustes */
|
||
.cli{border:1px solid var(--line);border-radius:6px;margin-bottom:8px}
|
||
.cli .cab2{display:flex;gap:10px;align-items:center;padding:10px 12px;cursor:pointer}
|
||
.cli .cab2 b{font-size:13.5px}
|
||
.cli .cab2 .tipo{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:99px;padding:0 7px}
|
||
.cli .cab2 .m{margin-left:auto;font-size:12px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%}
|
||
.cli .cab2 .caret{color:var(--mut);width:14px;text-align:center}
|
||
.cli .cuerpo{display:none;padding:4px 12px 12px;border-top:1px solid var(--line2)}
|
||
.cli.abierto{border-color:var(--acc)} .cli.abierto .cuerpo{display:block}
|
||
.cli .f{display:grid;grid-template-columns:150px 1fr;gap:8px 12px;align-items:start;margin-top:10px;font-size:12.5px}
|
||
.cli .f label{color:var(--mut);padding-top:6px}
|
||
.cli textarea{width:100%;min-height:54px;resize:vertical;font-family:ui-monospace,Consolas,monospace;font-size:12px}
|
||
.cli input[type=text]{width:100%}
|
||
/* barra de tareas */
|
||
#tareas{grid-column:2;position:sticky;bottom:0;background:var(--bg);border-top:1px solid var(--line);padding:7px 28px;display:none;gap:10px;flex-wrap:wrap;align-items:center;z-index:6;box-shadow:0 -6px 18px rgba(0,0,0,.04)}
|
||
#tareas.on{display:flex}
|
||
#tareas .tit{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);font-weight:600;margin-right:4px}
|
||
.tarea{display:flex;align-items:center;gap:10px;border:1px solid var(--line);border-radius:6px;padding:5px 10px;font-size:12px;background:var(--panel);min-width:260px;max-width:460px}
|
||
.tarea.run{border-color:var(--acc)}
|
||
.tarea .q{font-weight:600;white-space:nowrap}
|
||
.tarea .w{min-width:0;flex:1}
|
||
.tarea .w .d{color:var(--mut);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.tarea .b{height:5px;background:var(--panel2);border-radius:3px;overflow:hidden;margin-top:4px}
|
||
.tarea .b i{display:block;height:100%;background:var(--acc);min-width:2px;transition:width .3s}
|
||
.tarea .b.ind i{width:30%!important;animation:ind 1.2s infinite linear}
|
||
.tarea .pct{font-variant-numeric:tabular-nums;color:var(--mut);white-space:nowrap}
|
||
.tarea .pct small{display:block;font-size:11px;color:var(--mut);font-weight:500;text-align:right}
|
||
.tarea button{padding:3px 8px;font-size:11.5px}
|
||
/* avisos */
|
||
#avisos{position:fixed;right:18px;bottom:60px;display:flex;flex-direction:column;gap:8px;z-index:30;max-width:420px}
|
||
.aviso{background:var(--tx);color:#fff;border-radius:7px;padding:10px 14px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.18);display:flex;gap:10px;align-items:flex-start}
|
||
.aviso.bad{background:var(--bad)} .aviso.ok{background:var(--ok)}
|
||
.aviso a{color:#fff;font-weight:600;white-space:nowrap;cursor:pointer;text-decoration:underline}
|
||
.aviso .x{cursor:pointer;opacity:.7;margin-left:auto}
|
||
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<aside id="lado">
|
||
<div class="marca">
|
||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="#1f57d6"/><rect x="7" y="8" width="18" height="4" rx="2" fill="#fff"/><rect x="7" y="14" width="13" height="4" rx="2" fill="#fff" opacity=".85"/><rect x="7" y="20" width="8" height="4" rx="2" fill="#fff" opacity=".65"/></svg>
|
||
<div><div class="n">Organi<b>LAB</b></div><div class="s">material en orden</div></div>
|
||
</div>
|
||
<div class="lado-tit">Carpetas de clientes</div>
|
||
<div id="indice"></div>
|
||
<select id="raiz" aria-hidden="true"></select>
|
||
<div id="zonaSoltar" role="button" tabindex="0" title="Suelta aquí una carpeta de cliente para añadirla, o haz clic para pegar la ruta">Suelta aquí la carpeta<br>de un cliente para añadirla</div>
|
||
<button class="lnk" id="btnElegir" style="width:100%;margin-top:4px" title="Abre el selector de carpetas de Windows">Elegir carpeta…</button>
|
||
<div class="lado-pie"><img src="/static/labstream.png" alt="Labstream Studio"><span id="reloj"></span><button id="btnBuscarAct" title="Consulta Labstream OS y avisa si hay una versión nueva">Buscar actualizaciones</button><span id="actUltima" class="mut" style="font-size:11px"></span></div>
|
||
</aside>
|
||
<div id="principal">
|
||
<nav id="nav" aria-label="Vistas">
|
||
<a data-t="carpeta" class="on" tabindex="0">Carpeta</a>
|
||
<a data-t="revisar" tabindex="0">Revisar y ordenar</a>
|
||
<a data-t="mapa" tabindex="0" title="Cómo van a quedar las carpetas y qué va en cada una">Organizar carpetas</a>
|
||
<a data-t="convertir" tabindex="0">Convertidor</a>
|
||
<a data-t="respaldar" tabindex="0">Respaldar</a>
|
||
<a data-t="historial" tabindex="0">Historial</a>
|
||
<a data-t="ajustes" class="eng" tabindex="0" title="Clientes, sus carpetas y plantillas">⚙ Ajustes</a>
|
||
<a data-t="manual" class="eng2" tabindex="0" title="Qué hace cada cosa y cómo lo hace">Manual</a>
|
||
</nav>
|
||
<main>
|
||
|
||
<!-- ============ CARPETA ============ -->
|
||
<section id="t-carpeta" class="on">
|
||
<div class="box" id="actualizacionCaja" style="display:none"></div>
|
||
<div id="inicioVacio" style="display:none">
|
||
<div class="vacio-caja" id="vacioCaja" role="button" tabindex="0">
|
||
<h2>Suelta aquí la carpeta de un cliente</h2>
|
||
<p>OrganiLAB la escanea, te enseña qué pesa y te lleva paso a paso. No mueve nada sin que lo confirmes, y nunca borra.</p>
|
||
<div class="o"><button class="p" id="btnElegir2" type="button">Elegir carpeta…</button> o <u>pega la ruta</u></div>
|
||
</div>
|
||
<div id="portadaCarpetas"></div>
|
||
<div id="portadaAuto"></div>
|
||
<div class="vacio-pasos">
|
||
<div><b>1 · Escanear</b>Lee nombres, tamaños y fechas. Solo lectura.</div>
|
||
<div><b>2 · Revisar</b>Qué pesa: BRAW, cámara, stock, proxies, basura, repetidos.</div>
|
||
<div><b>3 · Ordenar</b>Mueve dentro de la misma carpeta. Lo que sobra va a _Para borrar.</div>
|
||
<div><b>4 · Convertir</b>Clips ya entregados a un formato más ligero (H.265 10 bits, H.264, ProRes), en cola uno tras otro.</div>
|
||
<div><b>5 · Respaldar</b>Copia compacta a otro disco, con índice.</div>
|
||
</div>
|
||
</div>
|
||
<div id="inicioLleno">
|
||
<div class="cab"><div><h2 class="v" id="cabTitulo">…</h2><div class="sub" id="cabSub"></div></div><div class="der"><button class="lnk" id="btnPieza" title="Crea la carpeta de una pieza con el número que toca y sus subcarpetas">Nueva pieza</button><button class="lnk" id="btnTraer" title="Copia una tarjeta a la bandeja de entrada comprobando cada archivo, y lo reparte">Traer material</button><button class="lnk" id="btnEscanear">Volver a escanear</button><button class="lnk" id="btnDesconectar" title="La carpeta deja de aparecer en la lista de la izquierda. No borra ni mueve nada del disco.">Quitar de la lista</button></div></div>
|
||
<div id="acciones" class="acc"></div>
|
||
<div class="sig" id="siguiente" style="display:none"></div>
|
||
<div class="row">
|
||
<div class="box plano"><h3>Qué hay en la carpeta</h3><div id="pesoBarra" class="peso"></div><div id="pesoLinea" class="peso-linea"></div><div id="inicioDiag" style="margin-top:8px;font-size:12.5px"></div></div>
|
||
<div class="box plano"><h3>Últimos cambios en esta carpeta</h3><div id="inicioLotes" class="mut">Todavía no se ha movido nada.</div></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ REVISAR Y ORDENAR ============ -->
|
||
<section id="t-revisar">
|
||
<h2 class="v">Revisar y ordenar</h2>
|
||
<p class="ayuda">Todo se mueve <b>dentro de la misma carpeta</b>, y lo único que se borra son las carpetas que se quedan vacías, si las marcas tú. <span class="q" title="Más">?</span><span class="mas">Basura y proxies van a <span class="path">_Para borrar</span>. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.</span></p>
|
||
<div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div>
|
||
<div id="revisarLleno" style="display:none">
|
||
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a></div>
|
||
|
||
<div class="sub on" id="s-hay">
|
||
<div class="cards" id="cards"></div>
|
||
<div class="row">
|
||
<div class="box"><h3>Qué pesa (primer nivel)</h3><div class="tm" id="treemap"></div><div class="legend" id="legend" style="margin-top:8px"></div></div>
|
||
<div class="box"><h3>Avisos</h3><div id="alertas"></div></div>
|
||
</div>
|
||
<details class="mas"><summary>Más detalle: cámaras, meses, tipos de archivo, repetidos, dónde vive cada cliente</summary>
|
||
<div class="row" style="margin-top:12px"><div class="box"><h3>Por fuente (de qué cámara viene)</h3><div id="fuentes"></div></div><div class="box"><h3>Material de cámara por mes</h3><div id="meses"></div></div></div>
|
||
<div class="row"><div class="box"><h3>Por tipo de archivo</h3><div id="exts"></div></div><div class="box"><h3>Repetidos (mismo nombre y peso, el mismo clip en MP4 y MOV, o con dos calidades)</h3><div id="dups"></div><div class="toolbar" style="margin-top:10px"><button id="btnComparar">Comparar y ver todos</button><button class="p" id="btnJuntar" disabled>Juntar en Duplicados</button><span class="mut" id="dupsResumen"></span></div></div></div>
|
||
<div class="box" style="margin-bottom:16px"><h3>Dónde vive cada cliente (todos los discos conectados)</h3><div id="clientesDiscos" class="mut">cargando…</div></div>
|
||
<div class="box"><h3>Discos</h3><div id="discos" class="mut">cargando…</div></div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="sub" id="s-plan">
|
||
<div id="planIntro" class="hint" style="margin-bottom:12px">Pulsa <b>Calcular</b> para ver qué haría la app en esta carpeta: lo explica con un ejemplo y lista lo demás. No mueve nada hasta que apliques.</div>
|
||
<div class="toolbar" style="margin-bottom:12px">
|
||
<button id="btnAplicarSeguros" class="ok" disabled style="order:-1">Aplicar</button>
|
||
<span class="mut" id="planUnClic" style="display:none;font-size:12.5px;order:-1">Un clic lo hace todo, en orden. No hay que ir uno por uno.</span>
|
||
<button id="btnPlan" class="p">Calcular qué va a pasar</button>
|
||
<span class="sep"></span>
|
||
<button class="lnk" id="btnArbol" disabled>Cómo quedaría</button>
|
||
<input type="text" id="filtroPlan" placeholder="buscar en el plan…" style="min-width:160px">
|
||
</div>
|
||
<div id="planAvance"></div>
|
||
<div id="planAvisos"></div>
|
||
<div id="planPasos" class="fichas"></div>
|
||
<div id="planEjemplo"></div>
|
||
<div id="planLista"></div>
|
||
</div>
|
||
|
||
<div class="sub" id="s-nombrar">
|
||
<p class="ayuda">Aquí solo salen las carpetas sobre las que hay algo que decidir: las que la app no sabe qué son, y que no va a mover hasta que se lo digas. Confirma la propuesta con un clic o elige otra cosa. <span class="q">?</span><span class="mas">No salen las carpetas de la propia app, ni lo de dentro de las piezas, ni lo que ya se ordena solo: una carpeta llena de clips de cámara se reparte sin preguntar, y un cajón con meses dentro se abre. La propuesta sale de la forma de la carpeta (si trae sus exportados, su portada y su música es una pieza; si guarda meses, es un cajón) y de lo que tiene dentro contando archivos, no peso. Lo que confirmes vale para las carpetas que se llamen exactamente igual, en cualquier disco. Quita «solo sin reconocer» para ver y corregir lo que ya reconoce.</span></p>
|
||
<div class="toolbar"><button id="btnAceptarSug" class="p" disabled>Aceptar las sugerencias seguras</button><input type="text" id="filtroVoc" placeholder="buscar carpeta…" style="min-width:200px"><label><input type="checkbox" id="soloSin" checked> solo sin reconocer</label><span class="sep"></span><span id="vocResumen" class="mut"></span></div>
|
||
<div class="box"><ul class="nombrar" id="nombrar"><li class="mut">cargando…</li></ul></div>
|
||
<details class="mas"><summary>Reglas exactas aprendidas al editar un destino en el plan</summary><div id="reglas" class="mut" style="margin-top:8px">ninguna todavía</div></details>
|
||
</div>
|
||
|
||
<div class="sub" id="s-stock">
|
||
<p class="ayuda">Las tomas de Envato de proyectos ya publicados sobran: se vuelven a descargar con la suscripción. <span class="q">?</span><span class="mas">Se detectan por el nombre (…-2026-01-22-22-34-00-utc) o por estar en una carpeta «Elements». Van a <span class="path">_Para borrar/stock</span>; el borrado definitivo lo hace una persona. Se deshace desde Historial.</span></p>
|
||
<div class="toolbar">
|
||
<label>marcar lo de más de <input type="number" id="stockMeses" value="3" min="0" style="width:56px"> meses</label>
|
||
<span class="mut" style="font-size:12px">rápido:</span><button class="lnk" data-m="1">1</button><button class="lnk" data-m="2">2</button><button class="lnk" data-m="3">3</button><button class="lnk" data-m="6">6</button><button class="lnk" data-m="12">12</button>
|
||
<label><input type="checkbox" id="stockVideo" checked> video</label>
|
||
<label><input type="checkbox" id="stockAudio"> música</label>
|
||
<label><input type="checkbox" id="stockImg"> imágenes</label>
|
||
<button id="btnStock" class="p">Buscar stock viejo</button>
|
||
<span class="sep"></span><span id="stockResumen" class="mut"></span>
|
||
<button id="btnStockPapelera" class="bad" disabled>Apartar a _Para borrar/stock</button><span class="por-que" id="btnStockPapeleraPorQue">Busca primero</span>
|
||
</div>
|
||
<div class="box" id="stockCaja" style="display:none;max-height:420px;overflow:auto"><table id="tablaStock"><thead><tr><th></th><th>Carpeta</th><th>Mes de la pieza</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div>
|
||
</div>
|
||
|
||
<div class="sub" id="s-vacias">
|
||
<p class="ayuda">Carpetas sin un solo archivo dentro: lo que queda cuando el material ya se movió. <b>Es lo único que la app borra</b>, y solo porque no hay nada que perder. <span class="q">?</span><span class="mas">Se comprueba en el disco antes de enseñarlas, y otra vez justo antes de borrarlas: si alguien dejó algo dentro mientras tanto, esa se salta y se avisa. <b>Deshacer</b> las vuelve a crear con su nombre. Las que son un enlace o una unión de Windows no se tocan nunca, aunque parezcan vacías. Fíjate en los nombres antes de marcar: una carpeta vacía llamada «03- Exfoliación química» es lo único que queda de esa pieza.</span></p>
|
||
<div class="toolbar">
|
||
<button id="btnVacias" class="p">Buscar carpetas vacías</button>
|
||
<button class="lnk" id="btnVaciasTodas" style="display:none">Marcar todas</button>
|
||
<span class="sep"></span><span id="vaciasResumen" class="mut"></span>
|
||
<button id="btnVaciasBorrar" class="bad" disabled>Borrar las marcadas</button><span class="por-que" id="btnVaciasBorrarPorQue">Busca primero</span>
|
||
</div>
|
||
<div class="box" id="vaciasCaja" style="display:none;max-height:460px;overflow:auto"><table id="tablaVacias"><thead><tr><th></th><th>Carpeta</th><th class="num">Subcarpetas vacías dentro</th></tr></thead><tbody></tbody></table></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ MAPA ============ -->
|
||
<section id="t-mapa">
|
||
<div class="cab"><div><h2 class="v">Organizar carpetas</h2><div class="sub" id="mapaSub">cómo van a quedar las carpetas y qué va en cada una</div></div></div>
|
||
<p class="ayuda">Este árbol sale de las plantillas de verdad, así que es exactamente lo que la app va a hacer al ordenar. <span class="q">?</span><span class="mas">Las carpetas en cursiva cambian según el material (el año, el mes, el nombre de la pieza, el programa del proyecto). Las cifras son el material que tienes hoy en esta carpeta, repartido por lo que es cada archivo. Toca cualquier carpeta del árbol para ver qué guarda y cambiar dónde va.</span></p>
|
||
<div class="toolbar">
|
||
<label>tipo de trabajo <select id="mapaTipo"></select></label>
|
||
<span class="sep"></span>
|
||
<button id="btnPintar" title="Le pone a cada carpeta un icono del color de lo que guarda, para verlo en el Explorador">Pintar las carpetas en Windows</button>
|
||
<button class="lnk" id="btnDespintar" style="display:none">Quitar los colores</button>
|
||
<span id="mapaResumen" class="mut"></span>
|
||
</div>
|
||
<div class="row mapa2">
|
||
<div class="box"><h3>Cómo van a quedar las carpetas</h3><div id="mapaArbol" class="arbol mut">cargando…</div></div>
|
||
<div class="lado">
|
||
<div class="box"><h3>Qué va en esta carpeta</h3><div id="mapaDetalle" class="det mut">Toca una carpeta del árbol para ver qué guarda y cambiar dónde va.</div></div>
|
||
<div class="box plano">
|
||
<h3>¿Dónde va a parar este archivo?</h3>
|
||
<p class="mut" style="font-size:12.5px;margin:0 0 8px">Escribe el nombre de cualquier archivo, exista o no, y te digo qué es, de qué cámara o grabadora salió y en qué carpeta lo dejaría. Lo decide el mismo clasificador que usa el escaneo.</p>
|
||
<input type="text" id="mapaArchivo" placeholder="A006_12171629_C001.braw" style="width:100%">
|
||
<div class="chips" id="mapaEjemplos"></div>
|
||
<div id="mapaRespuesta"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="box" id="mapaSinSitio" style="display:none"><h3>Sin carpeta asignada</h3><div class="mut" style="font-size:12.5px;margin-bottom:6px">Este material no tiene dónde ir con este tipo de trabajo: se queda donde está. Añádele una carpeta y la app empezará a ordenarlo.</div><div id="mapaSinSitioLista"></div></div>
|
||
</section>
|
||
|
||
<!-- ============ CONVERTIR ============ -->
|
||
<section id="t-convertir">
|
||
<div class="cab"><h2 class="v">Convertidor</h2><span class="sub">clips de cámara <b id="convCriterio">con más de 3 meses y más de 300 MB</b> · <details class="ajc" id="convAjustes"><summary>cambiar</summary><div class="toolbar">
|
||
<label>con más de <input type="number" id="optMeses" value="3" min="0" style="width:60px"> meses</label>
|
||
<label>mínimo <input type="number" id="optMin" value="300" min="0" style="width:70px"> MB</label>
|
||
<label>calidad <select id="optPerfil"></select></label>
|
||
<label>croma <select id="optChroma"><option value="420">4:2:0 10 bits (compatible)</option><option value="422">4:2:2 10 bits (solo ffmpeg, pesa más)</option></select></label>
|
||
<label>BRAW en <select id="optColor"><option value="rec709">Rec.709 (como se vio al grabar)</option><option value="log">Log de cámara (plano, más latitud)</option></select></label>
|
||
<label><input type="checkbox" id="optExport"> incluir exportados</label>
|
||
</div></details></span></div>
|
||
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW se leen con el decodificador de Blackmagic que instala DaVinci Resolve, sin tener que abrirlo; todo se codifica con ffmpeg, con la tarjeta gráfica cuando la hay. Lo que ya viene comprimido de cámara (H.264 o H.265 de pocos megabits) no se ofrece: ahorraría poco y perdería calidad. Nada se borra.</span></p>
|
||
<div class="toolbar" style="margin-bottom:10px"><label>formato de salida <select id="optFormato"></select></label><span id="fmtQue" class="mut" style="font-size:12px"></span><span class="sep"></span><span id="motores" class="mut" style="font-size:12px"><span class="spin"></span> detectando GPU y CPU…</span></div>
|
||
<div id="optCards"></div>
|
||
<div id="momento"></div>
|
||
<div class="toolbar">
|
||
<button id="btnBuscarClips" class="p">Buscar clips</button>
|
||
<button id="btnEncolar" disabled>Convertir</button>
|
||
<button id="btnReemplazar" class="ok" disabled>Reemplazar</button><span class="por-que" id="btnReemplazarPorQue"></span>
|
||
<span class="sep"></span>
|
||
<span id="colaEstado" class="est pendiente">sin actividad</span>
|
||
<button class="lnk" id="btnColaToggle" style="display:none">Detener</button>
|
||
<button class="lnk" id="btnReintentar" style="display:none">Reintentar los que fallaron</button>
|
||
<button class="lnk" id="btnVerificar" style="display:none">Volver a verificar</button>
|
||
<button class="lnk" id="btnQuitar" style="display:none">Limpiar terminados</button>
|
||
<button class="lnk" id="btnQuitarEspera" style="display:none">Quitar los que esperan</button>
|
||
</div>
|
||
<div id="anaEstado" class="mut" style="margin:-6px 0 10px"></div>
|
||
<div id="tanda"></div>
|
||
<details class="mas" id="colaAjustes" style="margin-bottom:12px"><summary>Cuándo trabaja la cola y qué mete sola</summary><div class="ajcola" id="colaAjustesCuerpo"></div></details>
|
||
<div class="box" id="convCaja"><div id="convLista"><span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span></div></div>
|
||
</section>
|
||
|
||
<!-- ============ RESPALDAR ============ -->
|
||
<section id="t-respaldar">
|
||
<h2 class="v">Respaldar</h2>
|
||
<p class="ayuda">Último paso: cuando la carpeta ya está limpia, ordenada y convertida, copia a otro disco una versión <b>compacta</b> del cliente. Puedes ir por lotes: solo lo de más de 3, 6 o 12 meses. <span class="q">?</span><span class="mas">Misma estructura de carpetas, cada una separada por tipo de medio (Video, Audio, Imagenes, Diseño, Proyectos, Documentos). Deja <span class="path">INDICE.csv</span> y <span class="path">_MAPA.md</span> en la raíz del respaldo. Es incremental: lo que ya está igual no se vuelve a copiar. Los originales de cámara sin convertir no se copian salvo que marques «incluir BRAW / RAW».</span></p>
|
||
<div id="respAviso"></div>
|
||
<div class="toolbar">
|
||
<label>destino <input type="text" id="respDestino" placeholder="T:\Archivo Labstream" style="min-width:300px"></label>
|
||
<label>qué copiar <select id="respMeses"><option value="0">todo el cliente</option><option value="3">solo lo de más de 3 meses</option><option value="6">solo lo de más de 6 meses</option><option value="12">solo lo de más de 12 meses</option></select></label>
|
||
<label><input type="checkbox" id="respTipo" checked> subcarpeta por tipo de medio</label>
|
||
<label><input type="checkbox" id="respSinStock" checked> sin stock de Envato</label>
|
||
<label><input type="checkbox" id="respRaw"> incluir BRAW / RAW sin convertir</label>
|
||
<button id="btnRespCalc" class="p">Calcular</button>
|
||
<button id="btnRespCopiar" class="ok" disabled>Copiar</button><span class="por-que" id="btnRespCopiarPorQue">Calcula primero</span>
|
||
<button id="btnRespDetener" class="bad" disabled>Detener</button>
|
||
<span id="respEstado" class="mut"></span>
|
||
</div>
|
||
<div class="cards" id="respCards" style="grid-template-columns:repeat(auto-fit,minmax(150px,1fr))"></div>
|
||
<div class="bar" style="margin-bottom:14px"><i id="respBarra" style="width:0%"></i></div>
|
||
<div class="row">
|
||
<div class="box"><h3>Por tipo de medio</h3><div id="respTipos" class="mut">calcula para verlo</div></div>
|
||
<div class="box"><h3>Originales de cámara que NO se copian (conviértelos primero)</h3><div id="respRawLista" class="mut">calcula para verlo</div></div>
|
||
</div>
|
||
<div class="box"><h3>Muestra de cómo queda</h3><div id="respMuestra" class="path mut">calcula para verlo</div></div>
|
||
<div class="box" style="margin-top:14px"><h3>Errores de la copia</h3><pre id="respErrores" class="mut">ninguno</pre></div>
|
||
</section>
|
||
|
||
<!-- ============ HISTORIAL ============ -->
|
||
<section id="t-historial">
|
||
<div class="cab"><h2 class="v">Historial</h2><div class="der"><label class="mut" style="font-size:12.5px"><input type="checkbox" id="bitSoloRaiz" checked> solo esta carpeta</label></div></div>
|
||
<p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, y <b>Deshacer</b> devuelve cada archivo a donde estaba. <span class="q">?</span><span class="mas">Si alguien movió un archivo después, ese se salta y se avisa. La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p>
|
||
<div id="papeleraLinea" class="hint" style="display:none"></div>
|
||
<div id="historial"></div>
|
||
</section>
|
||
|
||
<!-- ============ MANUAL ============ -->
|
||
<section id="t-manual">
|
||
<div class="cab"><h2 class="v">Manual</h2></div>
|
||
<p class="ayuda">Cada función del programa: qué hace y, en gris, cómo lo hace. El orden es el de las pestañas de arriba.</p>
|
||
<div class="manual">
|
||
|
||
<h3>Para empezar</h3>
|
||
<dl>
|
||
<dt>Instalarlo</dt><dd>La primera vez que se abre pregunta si lo instala en el equipo: queda en el menú Inicio, en el Escritorio y en Configuración › Aplicaciones. <i>Cómo: se copia con sus datos a tu carpeta de programas y se da de alta desde ahí; no pide permisos de administrador y de la carpeta original no borra nada.</i></dd>
|
||
<dt>Añadir la carpeta de un cliente</dt><dd>Suéltala en la ventana, pulsa «Elegir carpeta…» o pega su ruta. Pregunta el nombre del cliente y qué clase de trabajo le haces: cuatro tarjetas, cada una con los clientes que ya van así, y debajo el árbol de carpetas tal como va a quedar. <i>Cómo: al confirmar solo la lee; no mueve nada hasta que tú lo apliques.</i></dd>
|
||
<dt>Clase de trabajo</dt><dd>Piezas mes a mes, anuncios y campañas, un trabajo puntual o material de Labstream. <i>Cómo: cada clase tiene su plantilla de carpetas, y es lo único que decide dónde se guarda cada cosa.</i></dd>
|
||
<dt>Tus carpetas</dt><dd>Al abrir no se entra en ninguna: la portada lista las que ya conoce, cada una con su último escaneo, para entrar con un clic.</dd>
|
||
</dl>
|
||
|
||
<h3>Carpeta</h3>
|
||
<dl>
|
||
<dt>Escanear</dt><dd>Lee nombres, tamaños y fechas de todo lo que hay dentro. <i>Cómo: recorre la carpeta sin abrir los archivos, guarda un inventario y clasifica cada cosa por su extensión, su nombre y la carpeta donde está: cámara, proyecto, exportado, stock, proxy, basura…</i></dd>
|
||
<dt>Lista de acciones</dt><dd>Todo lo que se puede hacer ahora en esa carpeta, en orden, cada cosa con su cifra y su botón; lo ya hecho sale tachado. <i>Cómo: son consultas al inventario, por eso es instantánea.</i></dd>
|
||
<dt>Hacer las N de una vez</dt><dd>Ejecuta seguidas las acciones que no necesitan que elijas nada: apartar basura y proxies, ordenar por fecha, ordenar lo demás, apartar stock viejo y juntar repetidos. <i>Cómo: solo entra lo que el plan da por seguro o probable; lo dudoso no entra nunca, tampoco si es material de cámara. Convertir, respaldar y borrar carpetas vacías tienen su propio botón.</i></dd>
|
||
<dt>Nueva pieza</dt><dd>Crea la carpeta de una pieza en su mes, con el número que le toca y sus subcarpetas (Guion, Videos, Imagenes, Musica, Elements, IA, Portada, Exportado), para que todas se llamen igual. <i>Cómo: mira qué números hay ya en ese mes y pone el siguiente; si la pieza ya existe, lo dice y no crea otra. Mientras siga vacía se deshace desde el Historial, y no sale entre las carpetas vacías por borrar.</i></dd>
|
||
<dt>Bandeja de entrada</dt><dd>Lo que se deje en la carpeta <span class="path">_Entrada</span> del cliente (una tarjeta volcada, clips sueltos, una carpeta con el nombre de la sesión) aparece en la lista como «Repartir» y va solo a su mes, a su día de rodaje y a su cámara. <i>Cómo: el plan abre siempre esa carpeta. Los índices y miniaturas que deja la cámara en la tarjeta van a <span class="path">_Para borrar</span>; lo que no sabe dónde poner se queda en la bandeja.</i></dd>
|
||
<dt>Traer material</dt><dd>Copia una tarjeta, o cualquier carpeta de fuera, a la bandeja de entrada y la reparte. De la tarjeta no borra ni mueve nada. <i>Cómo: vuelve a leer cada archivo copiado y lo compara con el original; conserva la fecha del archivo, que es la del rodaje, y deja la lista de lo copiado con su huella en <span class="path">.organilab\entradas</span>. Si se corta, al repetirlo se retoma donde iba.</i></dd>
|
||
<dt>Quitar de la lista</dt><dd>La carpeta deja de aparecer en el programa y, si quieres, se olvida su inventario. En el disco no se toca nada.</dd>
|
||
</dl>
|
||
|
||
<h3>Revisar y ordenar</h3>
|
||
<dl>
|
||
<dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd>
|
||
<dt>Por nombrar</dt><dd>Las carpetas que no sabe qué son y que no moverá hasta que se lo digas. Propone qué es cada una y tú lo confirmas. No pregunta por las carpetas de la propia app, ni por lo de dentro de las piezas, ni por lo que ya ordena solo. <i>Cómo: mira la forma (si trae sus exportados, su portada y su música es una pieza; si guarda meses dentro, un cajón) y lo que tiene dentro contando archivos, no peso. Lo confirmado se guarda en un diccionario y vale para las carpetas que se llamen exactamente igual, en cualquier disco.</i></dd>
|
||
<dt>Qué va a pasar</dt><dd>El plan: qué haría la app en la carpeta, sin mover nada todavía. Lo explica con una carpeta de ejemplo, que se ve pasar de donde está a donde va, y debajo lista lo demás. Son tres pasos (apartar basura y proxies, ordenar por año y mes, ordenar lo demás) y un solo clic aplica todo lo marcado, en orden: no hay que ir uno por uno. Lo que no va solo queda aparte y contado: los cambios cosméticos (solo el nombre, «01 - Celular» → «01- Celular») y los dudosos. Mientras aplica, una barra dice por dónde va, archivo a archivo, y al acabar, qué quedó sin hacer. <i>Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada cambio va como seguro, probable o dudoso, y solo los dos primeros se marcan solos.</i></dd>
|
||
<dt>Grabaciones</dt><dd>Una carpeta de grabación se reparte por mes, día y cámara, y conserva el nombre de cada sesión («Tomas consultorio»). Un clip ya convertido sigue contando como de su cámara. <i>Cómo: solo se reparte lo que de verdad es una tarjeta volcada; si dentro hay una pieza montada (exportados, portada, proyecto), no se desarma. El año de un clip de Blackmagic, que el nombre no trae, sale del resto de clips de la misma tarjeta y día, y un rodaje que pasa de medianoche cuenta como uno solo.</i></dd>
|
||
<dt>Cajones</dt><dd>Una carpeta que guarda meses o años dentro («Orden», «Proyectos\Mayo») no se mueve entera: se abre, y cada cosa va a su mes. Si una pieza ya existe en ese mes, se junta con ella en vez de quedar una carpeta gemela al lado.</dd>
|
||
<dt>Grabaciones</dt><dd>Lo que sale de las cámaras y las grabadoras va siempre a su mes, dentro a su día de rodaje («13- Jueves») y dentro a una carpeta por fuente: <span class="path">URSA_BRAW</span>, <span class="path">BLACKMAGIC_MP4</span>, <span class="path">SONY</span>, <span class="path">OSMO</span>, <span class="path">DJI</span>, <span class="path">DJI_MIC</span>, <span class="path">TASCAM</span>, <span class="path">ZOOM</span>, <span class="path">CELULAR</span>, <span class="path">FOTOS</span>. <i>Cómo: la fuente se reconoce por el nombre que pone cada aparato (el consecutivo de la cámara, «DJI_01_…» del DJI Mic, «TASCAM_0389»); en «Organizar carpetas › ¿Dónde va a parar este archivo?» se ve de cuál viene cualquier nombre.</i></dd>
|
||
<dt>Repaso de lo ya ordenado</dt><dd>El plan también mira lo que ya está en <span class="path">00_Grabacion</span>: si un clip lleva en el nombre una fecha que no es la del mes donde está, propone llevarlo al suyo; y lo que está sin carpeta de día, o con la grabadora en «AUDIO», lo lleva a su día y a su carpeta. <i>Cómo: solo con fechas de fiar (escritas por la cámara, o la fecha de un original de cámara); lo demás se queda donde está.</i></dd>
|
||
<dt>Aplicar</dt><dd>Hace los movimientos marcados, siempre dentro de la carpeta del cliente. <i>Cómo: no pisa nada; si en el destino ya hay un archivo con ese nombre, lo deja donde está y lo avisa. Cada movimiento queda apuntado para poder deshacerlo.</i></dd>
|
||
<dt>Stock viejo</dt><dd>Aparta a <span class="path">_Para borrar\stock</span> las tomas de Envato de piezas de más de 1, 2, 3, 6 o 12 meses, que se vuelven a descargar con la suscripción. <i>Cómo: las reconoce por el nombre (terminan en «-utc») o por estar en una carpeta «Elements».</i></dd>
|
||
<dt>Repetidos</dt><dd>Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta <span class="path">Duplicados</span>. También junta el mismo clip guardado con dos calidades: se queda el de más imagen. <i>Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca. Dos clips de cámara que se llaman igual y pesan distinto se leen: si duran lo mismo son el mismo clip, y si no, son dos clips distintos y se dejan. Dos exportados con el mismo nombre no se tocan: son dos entregas de la pieza.</i></dd>
|
||
<dt>Carpetas vacías</dt><dd>Lista los huecos que deja ordenar y los borra. Es lo único que el programa borra, porque dentro no hay nada. <i>Cómo: cada una se comprueba en el disco justo antes; se puede deshacer, y no entra en «hacer de una vez».</i></dd>
|
||
</dl>
|
||
|
||
<h3>Organizar carpetas</h3>
|
||
<dl>
|
||
<dt>El árbol</dt><dd>Cómo quedan las carpetas con cada clase de trabajo y qué va en cada una, con el color de lo que guarda. <i>Cómo: se dibuja con las plantillas de verdad, así que enseña exactamente lo que el programa va a hacer.</i></dd>
|
||
<dt>Tocar una carpeta</dt><dd>Dice qué guarda y cuánto material de eso hay hoy, y deja cambiar ahí mismo a dónde va. «Sin carpeta asignada» lista lo que todavía no tiene sitio.</dd>
|
||
<dt>Cambiar un nombre</dt><dd>Renombra una carpeta en todas las plantillas que pasan por ella y, si quieres, también las que ya existen en el disco. <i>Cómo: es un movimiento normal, queda en el historial y se deshace.</i></dd>
|
||
<dt>¿Dónde va a parar este archivo?</dt><dd>Escribes el nombre de un archivo, exista o no, y dice qué es, de qué cámara o programa viene y en qué carpeta lo dejaría. <i>Cómo: con el mismo clasificador que usa el escaneo.</i></dd>
|
||
<dt>Colores en Windows</dt><dd>Pinta las carpetas en el Explorador con el color de lo que guardan, y los quita con un clic. <i>Cómo: Windows no tiene color de carpeta, así que les pone un icono de color; hay discos que no lo permiten, y entonces lo dice y no deja nada escrito.</i></dd>
|
||
</dl>
|
||
|
||
<h3>Convertidor</h3>
|
||
<dl>
|
||
<dt>Cuándo trabaja la cola</dt><dd>Un desplegable en Convertir: un horario fuera del cual la cola espera entre clip y clip; esperar si Resolve, Premiere o After Effects están usando la tarjeta (nunca corta un clip a medias); «aligerar solo lo viejo», que en la pasada automática de cada noche mete en cola lo de más de N meses hasta un tope de GB (convierte y verifica, no reemplaza); y un aviso de Windows al terminar. <i>Cómo: la cola mira el horario y los programas de edición antes de cada clip; si tiene que esperar, lo dice en el panel de arriba.</i></dd>
|
||
<dt>Resumen de la tanda</dt><dd>Al acabar la cola queda una ficha: clips, peso antes y después, lo que se libera, la nota media si se midió, los fallos, y el reparto por carpeta, con «Exportar CSV» y «Abrir la carpeta». Sale en Convertir hasta que la ocultes o empiece otra, y queda en Historial.</dd>
|
||
<dt>Marcar con criterio</dt><dd>Sobre la lista del convertidor: todos los válidos, los de más de 5 GB, por cámara, por mes, de más de un año, ninguno; y un buscador por nombre o carpeta.</dd>
|
||
<dt>Comparar</dt><dd>En un clip convertido, enseña el mismo cuadro del original y del convertido, lado a lado, en tres instantes del clip; al pasar el ratón, una lupa amplía el mismo recorte en los dos. «Medir la calidad» da la nota de 0 a 100 (VMAF) sobre unos diez segundos del medio del clip; de 93 arriba no se nota a simple vista. Desde ahí se reemplaza ese original solo. <i>Cómo: un BRAW se revela con la misma curva con que se convirtió; si el original ya se reemplazó, se busca en _Para borrar/originales. La nota se guarda en el trabajo y sale en la lista.</i></dd>
|
||
<dt>Buscar clips</dt><dd>Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero, los exportados y lo que ya convirtió este programa, para no pasar un clip dos veces.</i></dd>
|
||
<dt>Formato y calidad</dt><dd>H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.</dd>
|
||
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Arriba hay un solo botón grande, el que toca en ese momento: buscar, convertir, detener la tanda (con cuánto falta y a qué hora acaba) o reemplazar lo verificado. La lista es una sola, por carpetas: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra y lo que le queda, convertido, verificado, falló); «primero este» adelanta un clip; se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos, y los excluidos por grupos con su porqué) y por qué ordenar (carpeta, estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW los lee el decodificador de Blackmagic que viene con DaVinci Resolve, sin abrirlo. No se ofrece lo que ya viene comprimido de cámara ni lo que ya convirtió el programa: reconvertirlo ahorra poco y pierde calidad.</i></dd>
|
||
<dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd>
|
||
<dt>Reemplazar</dt><dd>El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a <span class="path">_Para borrar\originales</span>.</dd>
|
||
</dl>
|
||
|
||
<h3>Respaldar</h3>
|
||
<dl>
|
||
<dt>Calcular</dt><dd>Dice cuánto ocuparía la copia, por tipo de medio, y qué originales de cámara quedarían fuera por no estar convertidos.</dd>
|
||
<dt>Copiar</dt><dd>Copia a otro disco una versión ligera: misma estructura, los clips en su versión convertida, sin stock, proxies ni basura. Todo, o solo lo de más de 3, 6 o 12 meses. <i>Cómo: es incremental; lo que ya está igual se salta, así que se puede detener y retomar. Deja <span class="path">INDICE.csv</span> y <span class="path">_MAPA.md</span> para encontrar cosas sin montar el disco.</i></dd>
|
||
</dl>
|
||
|
||
<h3>Historial</h3>
|
||
<dl>
|
||
<dt>Deshacer</dt><dd>Cada vez que el programa mueve algo queda aquí como un lote, y «Deshacer» devuelve cada archivo a donde estaba. <i>Cómo: en orden inverso; si alguien movió un archivo después, ese se salta y se avisa.</i></dd>
|
||
<dt>_Para borrar</dt><dd>Enseña lo que hay apartado. El programa no lo borra nunca: lo revisa y lo borra una persona desde el Explorador.</dd>
|
||
</dl>
|
||
|
||
<h3>Ajustes</h3>
|
||
<dl>
|
||
<dt>Clientes</dt><dd>Nombre, clase de trabajo, sus carpetas y los otros nombres con que aparece en los discos.</dd>
|
||
<dt>Actualizaciones</dt><dd>«Buscar actualizaciones» mira si hay versión nueva; el interruptor de «consultar al arrancar» viene apagado. <i>Cómo: descarga el paquete de Labstream OS, comprueba su firma y su huella, se cierra, se sustituye sin tocar tus datos y vuelve a abrirse.</i></dd>
|
||
<dt>Plantillas</dt><dd>En «Avanzado»: la ruta exacta de cada tipo de material en cada clase de trabajo. Es lo mismo que se cambia desde «Organizar carpetas».</dd>
|
||
</dl>
|
||
|
||
<h3>La ventana</h3>
|
||
<dl>
|
||
<dt>En curso</dt><dd>La barra de abajo enseña el avance de lo que esté corriendo (escaneo, lectura de clips, conversión, copia) con su botón de detener; el mismo avance sale en el botón de la barra de tareas de Windows, que parpadea al terminar algo largo.</dd>
|
||
<dt>Cerrar con trabajo en marcha</dt><dd>Avisa de lo que está haciendo; si cierras, sigue hasta terminar y se cierra solo. Abrirlo otra vez devuelve la misma ventana.</dd>
|
||
<dt>Una sola copia</dt><dd>Abrirlo dos veces trae al frente la ventana que ya había. <i>Cómo: dos copias sobre los mismos datos se pisarían el inventario.</i></dd>
|
||
</dl>
|
||
|
||
<h3>Sin abrir el programa</h3>
|
||
<dl>
|
||
<dt>Arrastrar una carpeta</dt><dd>Sobre el .exe o sobre «Organizar carpeta (arrastra aqui).bat»: la escanea, da el diagnóstico y pregunta paso a paso en una consola.</dd>
|
||
<dt>Por comando</dt><dd><span class="path">OrganizadorLabstream.exe ayuda</span> lista los comandos: asistente, organizar, escanear, stock, respaldo, duplicados, deshacer, actualizar, automatico, instalar y desinstalar.</dd>
|
||
<dt>Orden automático</dt><dd>En la portada. Encendido, el programa pone al día todas las carpetas cada día a la hora que digas: lee cada una y hace solo lo seguro. El resumen de la última pasada queda en la portada, carpeta por carpeta, y «Hacerlo ahora» lo lanza a mano. Viene apagado. <i>Cómo: lo abre una tarea del Programador de tareas de Windows, con tu sesión iniciada, que es cuando están montados los discos. Si una carpeta no se puede leer entera, o si su plan mueve más de 3.000 archivos, no la toca y lo dice. Cada cambio se deshace desde el Historial.</i></dd>
|
||
</dl>
|
||
|
||
<h3>Lo que nunca hace</h3>
|
||
<dl>
|
||
<dt>Borrar archivos</dt><dd>Lo que sobra va a <span class="path">_Para borrar</span>, dentro de la misma carpeta del cliente, y lo repetido a <span class="path">Duplicados</span>.</dd>
|
||
<dt>Pisar o sacar de su carpeta</dt><dd>No sobrescribe nada y no mueve nada fuera de la carpeta del cliente.</dd>
|
||
<dt>Salir a la red</dt><dd>Todo ocurre en este equipo. Solo se conecta cuando le pides buscar una versión nueva.</dd>
|
||
</dl>
|
||
|
||
<h3>Dónde guarda lo suyo</h3>
|
||
<dl>
|
||
<dt>La carpeta datos</dt><dd>Dentro de la carpeta del programa: el inventario, el historial, los ajustes y lo que ha aprendido. Si cambias de equipo, llévate esa carpeta.</dd>
|
||
</dl>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ AJUSTES ============ -->
|
||
<section id="t-ajustes">
|
||
<div class="cab"><h2 class="v">Clientes</h2><div class="der"><input type="text" id="filtroCli" placeholder="buscar cliente…" style="min-width:200px"><button id="btnAddCliente">+ Añadir cliente</button></div></div>
|
||
<p class="ayuda">Un cliente puede tener material en varias carpetas de varios discos; aquí se dice cuáles son y con qué otros nombres aparece. <span class="q">?</span><span class="mas">Las carpetas registradas son las que la app escanea y ordena. Los otros nombres (alias) sirven para reconocer al cliente en carpetas de otros discos. Casi nunca hace falta tocar nada: al soltar una carpeta en la barra lateral se registra sola.</span></p>
|
||
<div id="clientes"></div>
|
||
<div class="box" style="margin-top:18px"><h3>Actualizaciones</h3>
|
||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="actAuto" style="margin-top:3px"><span>Al arrancar, consultar si hay una versión nueva en Labstream OS. <span class="mut">Es lo único que sale a la red; sin esto el programa no habla con nada fuera de este equipo. El botón «Buscar actualizaciones» sigue funcionando a mano.</span></span></label>
|
||
</div>
|
||
<details class="mas" style="margin-top:18px"><summary>Avanzado: plantillas de estructura por tipo de trabajo</summary>
|
||
<div class="box" style="margin-top:10px">
|
||
<div class="mut" style="margin-bottom:8px">Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.</div>
|
||
<div id="plantillas"></div>
|
||
<div class="toolbar" style="margin-top:10px"><button id="btnGuardarPlantillas" class="p">Guardar plantillas</button></div>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
<div id="tareas" aria-live="polite"><span class="tit">En curso</span></div>
|
||
<div id="avisos"></div>
|
||
|
||
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitulo"><div class="box"><h3 id="modalTitulo">…</h3><div id="modalCuerpo"></div><div class="toolbar" style="margin-top:12px;justify-content:flex-end"><button id="modalNo">Cancelar</button><button id="modalSi" class="p">Confirmar</button></div></div></div>
|
||
|
||
<script>
|
||
const $ = s => document.querySelector(s);
|
||
const $$ = s => Array.from(document.querySelectorAll(s));
|
||
const gb = b => { b = b || 0; if (b >= 1e12) return (b/1e12).toFixed(2) + ' TB'; if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB'; if (b >= 1e6) return (b/1e6).toFixed(0) + ' MB'; return (b/1e3).toFixed(0) + ' KB'; };
|
||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const fecha = ts => ts ? new Date(ts*1000).toLocaleString('es-CO', {dateStyle:'medium', timeStyle:'short'}) : '';
|
||
const fechaCorta = ts => ts ? new Date(ts*1000).toLocaleDateString('es-CO', {day:'numeric', month:'short'}) : '';
|
||
const hora = ts => ts ? new Date(ts*1000).toLocaleTimeString('es-CO', {hour:'2-digit', minute:'2-digit'}) : '';
|
||
const num = n => (n || 0).toLocaleString('es-CO');
|
||
const dur = s => { s = Math.round(s||0); return s >= 3600 ? `${Math.floor(s/3600)}h ${Math.floor(s%3600/60)}m` : `${Math.floor(s/60)}m ${s%60}s`; };
|
||
const COLORES = {ANIO:'#4f8cff',GRABACION:'#e0574f',PIEZA:'#7c5cff',CAMPANA:'#ff8c42',EXPORTADOS:'#3ecf8e',PORTADAS:'#f5b84b',APOYOS:'#d96fd9',MARCA:'#39b8c9',PROYECTOS:'#8fa1b8',MUSICA:'#c9b339',SFX:'#c9b339',VOZ:'#c9b339',IA:'#a78bfa',STOCK:'#5aa8e0',CONTENEDOR:'#6b7280',REVISAR:'#ff6b6b',PROXY:'#555c6b',BASURA:'#3a3f4a',APARTADO:'#9aa3ad','':'#4b5563'};
|
||
const NOMBRE_CUBETA = {ANIO:'año',GRABACION:'grabación',PIEZA:'pieza',CAMPANA:'campaña',EXPORTADOS:'exportados',PORTADAS:'portadas',APOYOS:'apoyos',MARCA:'marca',PROYECTOS:'proyectos',MUSICA:'música',SFX:'sonidos',VOZ:'voz',IA:'IA',STOCK:'stock',CONTENEDOR:'contenedor',REVISAR:'por revisar',PROXY:'proxies',BASURA:'basura',CELULAR:'celular',FOTOS:'fotos',APARTADO:'apartado por la app'};
|
||
const SEGURIDAD = {alta:'seguro', media:'revisar', baja:'dudoso'};
|
||
let ESTADO = null, MAPA = null, PLAN = null, CAND = [], VOC = [], STOCK = [], RESP = null, CARPETA = null;
|
||
|
||
// ---------------- red y errores ----------------
|
||
class ApiError extends Error {}
|
||
async function api(u, body){
|
||
let r;
|
||
try { r = await fetch(u, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : undefined); }
|
||
catch (e) { throw new ApiError('No hay conexión con OrganiLAB. ¿Se cerró el programa?'); }
|
||
let j = null; try { j = await r.json(); } catch (e) { throw new ApiError(`Respuesta rara del programa (${r.status})`); }
|
||
// el JSON del error viaja con la excepción: trae la traza del servidor o, en «Desconectar», qué conversiones estorban
|
||
if (!r.ok || (j && j.error)) { const e = new ApiError((j && j.error) || `Error ${r.status}`); e.datos = j || {}; throw e; }
|
||
return j;
|
||
}
|
||
// Los avisos de error se apuntan (últimos 40, en este navegador) para poder leerlos después de que el globo
|
||
// desaparezca: salen en Historial › «Avisos recientes», con su detalle si lo hay.
|
||
const AVISOS_CLAVE = 'organilab.avisos';
|
||
function avisosGuardados(){ try { return JSON.parse(localStorage.getItem(AVISOS_CLAVE) || '[]'); } catch (e) { return []; } }
|
||
function apuntarAviso(texto, detalle){ try { const l = avisosGuardados(); l.unshift({ts: Date.now() / 1000, texto, detalle: detalle || '', raiz: (typeof raizActual === 'function' && $('#raiz')) ? raizActual() : ''}); localStorage.setItem(AVISOS_CLAVE, JSON.stringify(l.slice(0, 40))); } catch (e) {} }
|
||
function aviso(texto, tipo, enlace, detalle){
|
||
const d = document.createElement('div'); d.className = 'aviso ' + (tipo || '');
|
||
if (tipo === 'bad') { apuntarAviso(texto, detalle); if (!enlace) enlace = {texto: 'Detalle', ir: () => modal('Qué pasó', `<p>${esc(texto)}</p>${detalle ? `<pre>${esc(detalle)}</pre>` : ''}<p class="mut" style="font-size:12px">Queda apuntado en Historial › Avisos recientes.</p>`, null)}; }
|
||
d.innerHTML = `<span>${esc(texto)}</span>${enlace ? `<a>${esc(enlace.texto)}</a>` : ''}<span class="x" title="Cerrar">✕</span>`;
|
||
if (enlace) d.querySelector('a').onclick = () => { enlace.ir(); d.remove(); };
|
||
d.querySelector('.x').onclick = () => d.remove();
|
||
$('#avisos').appendChild(d); setTimeout(() => d.remove(), tipo === 'bad' ? 12000 : 7000);
|
||
}
|
||
async function accion(btn, fn){
|
||
if (btn.disabled) return;
|
||
// se marca lo que pusimos nosotros: si la acción deja OTRA etiqueta (porque recalculó el plan, vació la
|
||
// lista o volvió a bloquear el botón), esa manda. Antes el finally la pisaba y quedaban botones
|
||
// habilitados con la cuenta vieja: «Aplicar todo lo marcado (23)» sobre un plan que ya no tenía 23.
|
||
const antes = btn.innerHTML, marca = '<span class="spin"></span>' + antes;
|
||
btn.disabled = true; btn.innerHTML = marca;
|
||
try { await fn(); }
|
||
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); }
|
||
finally { if (btn.innerHTML === marca) { btn.disabled = false; btn.innerHTML = antes; } }
|
||
}
|
||
// qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí
|
||
const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || ''));
|
||
function bloquear(id, motivo){ const b = $('#' + id); if (!b) return; b.disabled = !!motivo; const p = $('#' + id + 'PorQue'); if (p) p.textContent = motivo && motivo !== 'x' ? motivo : ''; }
|
||
const seguro = fn => async (...a) => { try { await fn(...a); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); } };
|
||
$$('.ayuda .q').forEach(q => q.onclick = () => q.closest('.ayuda').classList.toggle('abierta'));
|
||
|
||
function raizActual(){ return $('#raiz').value; }
|
||
function cfgRaiz(){ return (ESTADO.raices||[]).find(r => r.clave === raizActual()); }
|
||
|
||
// ---------------- vistas ----------------
|
||
function irA(t){ $$('nav a').forEach(x => x.classList.toggle('on', x.dataset.t === t)); $$('section').forEach(s => s.classList.toggle('on', s.id === 't-' + t)); pintarIndice(); cargarVista(t); }
|
||
function vistaActual(){ return ($$('nav a').find(a => a.classList.contains('on')) || {}).dataset?.t || 'carpeta'; }
|
||
$$('nav a').forEach(a => { a.onclick = () => irA(a.dataset.t); a.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); irA(a.dataset.t); } }; });
|
||
function subRev(s){ const venia = $('#s-plan').classList.contains('on'); $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); if (s === 'plan' && !venia && PLAN) setTimeout(animarEjemplo, 60); }
|
||
$$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s));
|
||
const cargarVista = seguro(async t => {
|
||
t = t || vistaActual();
|
||
const r = cfgRaiz();
|
||
if (t !== 'carpeta' && t !== 'ajustes' && t !== 'manual' && r) { try { CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raizActual())); pintarIndice(); } catch (e) { console.warn(e); } }
|
||
if (t === 'carpeta') await cargarCarpeta();
|
||
if (t === 'revisar') { await cargarMapa(); await cargarVoc(); if (PLAN) pintarPlan(); if (STOCK.length && STOCK[0].raiz !== raizActual()) { STOCK = []; $('#stockCaja').style.display = 'none'; } if (VACIAS && VACIAS.raiz !== raizActual()) { VACIAS = null; pintarVacias(); } }
|
||
if (t === 'mapa') await cargarMapaEst();
|
||
if (t === 'convertir') { pintarAjustesCola(); 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;
|
||
// la primera opción está vacía a propósito: al abrir el programa no se entra en ninguna carpeta, la
|
||
// portada las enseña y se elige una. Antes el desplegable caía solo en la primera registrada.
|
||
sel.innerHTML = '<option value=""></option>' + ESTADO.raices.map(r => `<option value="${esc(r.clave)}">${esc(r.cliente)}</option>`).join('');
|
||
sel.value = (antes && ESTADO.raices.some(r => r.clave === antes)) ? antes : '';
|
||
pintarIndice();
|
||
const fmt = $('#optFormato'); if (!fmt.options.length && ESTADO.formatos) { fmt.innerHTML = Object.entries(ESTADO.formatos).map(([k, v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); fmt.value = ESTADO.config.optimizar.formato || 'h265_10bit'; pintarFormato(); cargarMotores(); }
|
||
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
|
||
const r = cfgRaiz(); const a = r && r.analisis;
|
||
if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total} <button class="lnk" id="btnAnaDetener">Detener</button>`;
|
||
else if (a && a.error && vistaActual() === 'convertir') $('#anaEstado').innerHTML = `<span class="warn">${esc(a.error)}</span>`;
|
||
const bad = $('#btnAnaDetener'); if (bad) bad.onclick = () => accion(bad, async () => { await api('/api/optimizar/analizar/detener', {raiz: raizActual()}); });
|
||
const h = ESTADO.herramientas || {}; const faltas = [];
|
||
if (h.ffmpeg && !h.ffmpeg.ok) faltas.push('ffmpeg no encontrado: sin él no se puede leer ni convertir (pon ffmpeg.exe y ffprobe.exe en la carpeta ffmpeg\\ junto al programa)');
|
||
if (h.braw && !h.braw.ok) faltas.push('Falta el decodificador de Blackmagic RAW (se instala con DaVinci Resolve, vale el gratuito): los BRAW no se podrán convertir');
|
||
$('#reloj').innerHTML = (faltas.length ? `<span class="warn" title="${esc(faltas.join('\n'))}">⚠ ${faltas.length} aviso${faltas.length > 1 ? 's' : ''}</span> · ` : '') + `v${esc(ESTADO.version || '')}`;
|
||
pintarTareas();
|
||
pintarActualizacion();
|
||
const ac = ESTADO.actualizacion || {}; $('#actUltima').textContent = ac.comprobado ? `última comprobación ${hora(ac.comprobado)}` : '';
|
||
detectarCambios();
|
||
}
|
||
let BUSCANDO = false;
|
||
function detectarCambios(){
|
||
const ahora = huella(ESTADO); const antes = ULTIMO; ULTIMO = ahora;
|
||
if (!antes) return;
|
||
const raiz = raizActual();
|
||
const fin = (k, texto, ir) => { if (antes[k] && !ahora[k]) { aviso(texto, 'ok', ir); cargarVista(); } };
|
||
fin('esc:' + raiz, 'Escaneo terminado.');
|
||
if (antes['ana:' + raiz] && !ahora['ana:' + raiz]) { if (BUSCANDO || vistaActual() === 'convertir') { BUSCANDO = false; irA('convertir'); cargarCandidatos().catch(e => aviso(e.message, 'bad')); } else aviso('Lectura de clips terminada.', 'ok', {texto: 'Ver clips', ir: () => { irA('convertir'); cargarCandidatos(); }}); }
|
||
fin('cola', 'Conversión terminada.', {texto: 'Ver', ir: () => irA('convertir')});
|
||
if (antes.asis && !ahora.asis && vistaActual() === 'carpeta') pintarAcciones().catch(() => {});
|
||
fin('asis', 'Listo: se hizo todo lo seguro de esa carpeta.' + (ESTADO.asistente.resultado && ESTADO.asistente.resultado.errores && ESTADO.asistente.resultado.errores.length ? ` ${ESTADO.asistente.resultado.errores.length} avisos.` : ''), {texto: 'Ver historial', ir: () => irA('historial')});
|
||
fin('resp', 'Copia de respaldo terminada.', {texto: 'Ver', ir: () => irA('respaldar')});
|
||
if (antes.traer && !ahora.traer) {
|
||
const t = ESTADO.traer || {}, o = t.ordenado;
|
||
if (t.estado === 'listo') aviso(`Material traído y comprobado: ${num(t.copiados)} archivo${t.copiados !== 1 ? 's' : ''}${t.saltados ? ` (${num(t.saltados)} ya estaban)` : ''}.` + (o && !o.error ? ` Repartido: ${o.movimientos} movimiento${o.movimientos !== 1 ? 's' : ''}${o.quedan ? `; quedan ${o.quedan} en la bandeja por mirar` : ''}.` : o && o.error ? ' No se pudo repartir: ' + o.error : ' Está en la bandeja de entrada.'), 'ok', o && o.lote ? {texto: 'Deshacer el reparto', ir: () => deshacer(o.lote)} : null);
|
||
else if (t.estado === 'cancelado') aviso('Se dejó de traer material. Lo ya copiado está en la bandeja de entrada; al volver a traerlo se retoma.', 'ok');
|
||
else aviso(`Al traer el material, ${t.errores_n} archivo${t.errores_n !== 1 ? 's' : ''} no se pudieron copiar bien. Lo demás está en la bandeja de entrada, sin repartir.`, 'bad');
|
||
cargarVista();
|
||
}
|
||
if (antes.auto && !ahora.auto) { aviso('Orden automático terminado.', 'ok'); if (!raizActual()) pintarAuto().catch(() => {}); else cargarVista(); }
|
||
if ((antes.cola !== ahora.cola || ahora.cola) && vistaActual() === 'convertir' && !document.querySelector('#convLista input:focus')) cargarCola();
|
||
}
|
||
function huella(E){
|
||
const h = {};
|
||
E.raices.forEach(r => { if (r.progreso && r.progreso.estado === 'corriendo') h['esc:' + r.clave] = 1; if (r.analisis && r.analisis.estado === 'corriendo') h['ana:' + r.clave] = 1; });
|
||
if (E.cola.activa) h.cola = 1; if (E.asistente.activo) h.asis = 1; if (E.respaldo.estado === 'corriendo') h.resp = 1;
|
||
if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) h.traer = 1; if (E.automatico && E.automatico.activo) h.auto = 1;
|
||
return h;
|
||
}
|
||
let SONDEO = null;
|
||
async function sondear(){ if (SONDEO) return; SONDEO = cargarEstado().catch(e => console.warn(e)).finally(() => { SONDEO = null; }); }
|
||
setInterval(sondear, 2500);
|
||
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; VOC = []; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
|
||
function limpiarTablas(){
|
||
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; PLAN_ABIERTOS = new Set();
|
||
$('#planIntro').innerHTML = 'Pulsa <b>Calcular</b> para ver, paso a paso, qué haría la app en esta carpeta.'; estadoPlan();
|
||
$('#optCards').innerHTML = ''; $('#anaEstado').textContent = ''; $('#convLista').innerHTML = '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>'; bloquear('btnEncolar', 'x'); $('#btnEncolar').textContent = 'Convertir';
|
||
$('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero');
|
||
$('#respCards').innerHTML = ''; $('#respTipos').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respRawLista').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respMuestra').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respEstado').textContent = ''; bloquear('btnRespCopiar', 'Calcula primero');
|
||
}
|
||
$('#btnEscanear').onclick = () => accion($('#btnEscanear'), async () => { await api('/api/escanear', {raiz: raizActual()}); await cargarEstado(); });
|
||
|
||
// ---------------- pieza nueva ----------------
|
||
const MESES_LARGOS = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||
$('#btnPieza').onclick = () => {
|
||
const raiz = raizActual(), hoy = new Date(), a0 = hoy.getFullYear();
|
||
modal('Nueva pieza', `<p>La app crea la carpeta de la pieza en su mes, con el número que le toca y sus subcarpetas, para que todas se llamen igual.</p>
|
||
<label for="pzTitulo">Título</label><input type="text" id="pzTitulo" style="width:100%;margin-top:4px" placeholder="Por ejemplo: Ozempic face" maxlength="80">
|
||
<div class="toolbar" style="margin-top:10px"><label>Mes <select id="pzMes">${MESES_LARGOS.map((m, i) => `<option value="${i + 1}" ${i === hoy.getMonth() ? 'selected' : ''}>${m}</option>`).join('')}</select></label><label>Año <select id="pzAnio">${[a0 - 1, a0, a0 + 1].map(a => `<option ${a === a0 ? 'selected' : ''}>${a}</option>`).join('')}</select></label></div>
|
||
<div id="pzPrevia" class="hint" style="margin-top:10px">Escribe el título para ver dónde va a quedar.</div>`,
|
||
() => accion($('#btnPieza'), async () => {
|
||
const r = await api('/api/pieza', {raiz, titulo: $('#pzTitulo').value, anio: +$('#pzAnio').value, mes: +$('#pzMes').value});
|
||
MAPA = null; PLAN = null;
|
||
aviso(`Creada «${r.rel}» con ${r.subcarpetas.length} subcarpetas.`, 'ok', {texto: 'Abrir la carpeta', ir: () => api('/api/abrir', {ruta: r.ruta}).catch(e => aviso(e.message, 'bad'))});
|
||
await cargarEstado(); cargarVista();
|
||
}));
|
||
$('#modalSi').textContent = 'Crear'; $('#modalSi').disabled = true;
|
||
let turno = 0;
|
||
const previa = async () => {
|
||
const t = $('#pzTitulo').value.trim(), mio = ++turno;
|
||
if (!t) { $('#pzPrevia').textContent = 'Escribe el título para ver dónde va a quedar.'; $('#modalSi').disabled = true; return; }
|
||
try {
|
||
const r = await api(`/api/pieza/previa?raiz=${encodeURIComponent(raiz)}&titulo=${encodeURIComponent(t)}&anio=${$('#pzAnio').value}&mes=${$('#pzMes').value}`);
|
||
if (mio !== turno) return;
|
||
$('#pzPrevia').innerHTML = r.existe ? `<span class="warn">Esa pieza ya existe: <span class="path">${esc(r.existe)}</span></span>` : `Va a quedar en <span class="path">${esc(r.rel)}</span>, con: ${r.subcarpetas.map(esc).join(' · ')}`;
|
||
$('#modalSi').disabled = !!r.existe;
|
||
} catch (e) { if (mio === turno) { $('#pzPrevia').innerHTML = `<span class="warn">${esc(e.message)}</span>`; $('#modalSi').disabled = true; } }
|
||
};
|
||
$('#pzTitulo').oninput = previa; $('#pzMes').onchange = previa; $('#pzAnio').onchange = previa;
|
||
setTimeout(() => $('#pzTitulo').focus(), 50);
|
||
};
|
||
|
||
// ---------------- bandeja de entrada: traer material de una tarjeta ----------------
|
||
$('#btnTraer').onclick = () => {
|
||
const raiz = raizActual();
|
||
modal('Traer material nuevo', `<p>La app copia lo que haya en la tarjeta (o en cualquier carpeta de fuera) a la bandeja de entrada de este cliente, <b>vuelve a leer cada archivo copiado para comprobar que quedó igual</b>, y lo reparte por mes, día de rodaje y cámara. <b>De la tarjeta no borra ni mueve nada.</b></p>
|
||
<div class="toolbar"><input type="text" id="trOrigen" placeholder="La tarjeta o la carpeta con el material" style="flex:1;min-width:220px" aria-label="De dónde traerlo"><button id="trElegir" type="button">Elegir…</button></div>
|
||
<label for="trNombre" style="display:block;margin-top:10px">Nombre de la sesión <span class="mut">(si quieres: «Entrevista», «Evento marzo»…)</span></label><input type="text" id="trNombre" style="width:100%;margin-top:4px" maxlength="60">
|
||
<label style="display:block;margin-top:10px"><input type="checkbox" id="trOrdenar" checked> repartirlo al terminar de copiar</label>
|
||
<div id="trPrevia" class="hint" style="margin-top:10px">Elige de dónde traerlo.</div>
|
||
<p class="mut" style="font-size:12.5px;margin:10px 0 0">También puedes dejar el material a mano en la bandeja: <a href="#" id="trAbrir">abrir la bandeja de entrada</a>. Lo que dejes ahí sale en la lista de la carpeta como «Repartir».</p>`,
|
||
() => accion($('#btnTraer'), async () => {
|
||
const r = await api('/api/entrada/traer', {raiz, origen: $('#trOrigen').value.trim(), nombre: $('#trNombre').value.trim(), ordenar: $('#trOrdenar').checked});
|
||
aviso(`Trayendo ${num(r.n)} archivo${r.n !== 1 ? 's' : ''} (${gb(r.bytes)}). El avance está en la barra de abajo.`, 'ok');
|
||
await cargarEstado();
|
||
}));
|
||
$('#modalSi').textContent = 'Traer'; $('#modalSi').disabled = true;
|
||
const previa = async () => {
|
||
const o = $('#trOrigen').value.trim();
|
||
if (!o) { $('#trPrevia').textContent = 'Elige de dónde traerlo.'; $('#modalSi').disabled = true; return; }
|
||
try {
|
||
const r = await api('/api/entrada/plan', {raiz, origen: o, nombre: $('#trNombre').value.trim()});
|
||
$('#trPrevia').innerHTML = `Son <b>${num(r.n)}</b> archivo${r.n !== 1 ? 's' : ''} · <b>${gb(r.bytes)}</b>. Se copian a <span class="path">_Entrada\\${esc(r.carpeta)}</span>${r.libre != null ? ` (en el disco quedan ${gb(r.libre)} libres)` : ''}.`;
|
||
$('#modalSi').disabled = false;
|
||
} catch (e) { $('#trPrevia').innerHTML = `<span class="warn">${esc(e.message)}</span>`; $('#modalSi').disabled = true; }
|
||
};
|
||
$('#trElegir').onclick = seguro(async () => { const r = await api('/api/elegir-carpeta', {para: 'traer'}); if (r.ruta) { $('#trOrigen').value = r.ruta; previa(); } });
|
||
$('#trOrigen').onchange = previa; $('#trNombre').onchange = previa;
|
||
$('#trAbrir').onclick = e => { e.preventDefault(); api('/api/entrada/abrir', {raiz}).then(() => aviso('Bandeja de entrada abierta en el Explorador. Lo que dejes ahí saldrá en la lista de la carpeta.', 'ok')).catch(er => aviso(er.message, 'bad')); };
|
||
};
|
||
|
||
// ---------------- orden automático (portada) ----------------
|
||
const ESTADO_AUTO = {'al dia': ['ok', 'al día'], 'con dudas': ['', 'con dudas'], 'con errores': ['bad', 'con avisos'], 'demasiado': ['warn', 'sin tocar'], 'sin conexion': ['mut', 'sin conexión'], 'sin leer': ['warn', 'sin leer']};
|
||
async function pintarAuto(){
|
||
const caja = $('#portadaAuto'); if (!caja) return;
|
||
const a = await api('/api/automatico'); const u = a.ultimo, m = a.en_marcha || {};
|
||
const filas = u && u.carpetas ? u.carpetas.map(c => { const e = ESTADO_AUTO[c.estado] || ['', c.estado]; return `<tr><td><b>${esc(c.cliente || c.raiz)}</b></td><td><span class="${e[0]}">${esc(e[1])}</span></td><td>${c.movimientos ? num(c.movimientos) + ' movimiento' + (c.movimientos !== 1 ? 's' : '') : '—'}</td><td class="mut">${esc([c.dudosos ? c.dudosos + ' dudoso' + (c.dudosos !== 1 ? 's' : '') + ' por mirar' : '', c.detalle || '', (c.errores || []).length ? (c.errores || []).length + ' avisos' : ''].filter(Boolean).join(' · '))}</td></tr>`; }).join('') : '';
|
||
caja.innerHTML = `<h3 style="margin:22px 0 8px">Orden automático</h3><div class="box">
|
||
<label style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input type="checkbox" id="autoActivo" ${a.activo ? 'checked' : ''}> Poner al día todas las carpetas cada día a las <input type="time" id="autoHora" value="${esc(a.hora)}" style="width:110px"></label>
|
||
<p class="mut" style="font-size:12.5px;margin:8px 0 0">Windows abre el programa a esa hora, lee cada carpeta y hace solo lo seguro; lo dudoso lo deja para ti, y todo se deshace desde el Historial. Hace falta el equipo encendido y tu sesión abierta. Si en una carpeta el plan mueve más de ${num(a.limite)} archivos, no la toca y lo dice.</p>
|
||
<div class="toolbar" style="margin-top:10px"><button id="autoAhora" ${m.activo ? 'disabled' : ''}>${m.activo ? 'En marcha…' : 'Hacerlo ahora'}</button><span class="mut">${u ? `Última pasada: ${fecha(u.ts)} (${u.origen === 'programado' ? 'programada' : 'a mano'})` : 'Todavía no ha corrido ninguna vez.'}</span></div>
|
||
${filas ? `<table style="margin-top:8px"><tbody>${filas}</tbody></table>` : ''}</div>`;
|
||
const guardar = seguro(async () => { try { await api('/api/automatico/config', {activo: $('#autoActivo').checked, hora: $('#autoHora').value || '02:00'}); aviso($('#autoActivo').checked ? `Orden automático encendido: cada día a las ${$('#autoHora').value}.` : 'Orden automático apagado.', 'ok'); } finally { pintarAuto().catch(() => {}); } });
|
||
$('#autoActivo').onchange = guardar; $('#autoHora').onchange = () => { if ($('#autoActivo').checked) guardar(); };
|
||
$('#autoAhora').onclick = () => modal('Poner al día todas las carpetas', `<p>La app va a leer las <b>${(ESTADO.raices || []).length}</b> carpetas registradas, una tras otra, y a hacer en cada una solo lo seguro. Lo dudoso se queda como está. Nada se borra, y cada cambio se deshace desde el Historial.</p>`, () => accion($('#autoAhora'), async () => { await api('/api/automatico/ahora', {}); aviso('En marcha. El avance está en la barra de abajo.', 'ok'); await cargarEstado(); pintarAuto().catch(() => {}); }));
|
||
}
|
||
|
||
// ---------------- barra de tareas ----------------
|
||
function tareasDe(E){
|
||
const T = []; const porNombre = k => (E.raices.find(r => r.clave === k) || {cliente: k}).cliente;
|
||
E.raices.forEach(r => {
|
||
if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); }
|
||
if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); }
|
||
});
|
||
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
|
||
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
|
||
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.unidades_total ? E.aplicando.unidades * 100 / E.aplicando.unidades_total : E.aplicando.hechas * 100 / E.aplicando.total)});
|
||
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
|
||
if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) { const t = E.traer; const cop = t.estado === 'copiando'; T.push({raiz: t.raiz, q: cop ? 'Trayendo material' : 'Repartiendo lo traído', d: `${porNombre(t.raiz)} · ${num(t.hechos)} de ${num(t.total)} · ${gb(t.bytes)} de ${gb(t.bytes_total)}${t.actual ? ' · ' + t.actual.slice(-40) : ''}`, pct: cop && t.bytes_total ? Math.round(t.bytes / t.bytes_total * 100) : null, cancelar: cop ? () => api('/api/entrada/cancelar', {}) : null}); }
|
||
if (E.automatico && E.automatico.activo) { const a = E.automatico; T.push({raiz: a.raiz, q: 'Orden automático', d: `${a.raiz ? porNombre(a.raiz) + ' · ' : ''}${a.hechas} de ${a.total} carpetas`, pct: a.total ? Math.round(a.hechas * 100 / a.total) : null}); }
|
||
const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct});
|
||
return T;
|
||
}
|
||
// tiempo restante estimado: se recuerda cuándo se vio cada tarea por primera vez y a qué % iba; con eso se extrapola
|
||
const ETA = {};
|
||
function etaDe(t){
|
||
if (t.pct == null) return '';
|
||
const k = t.q + '|' + (t.raiz || ''); const ahora = Date.now();
|
||
const h = ETA[k] && ETA[k].pct <= t.pct ? ETA[k] : (ETA[k] = {t0: ahora, pct: t.pct});
|
||
const avance = t.pct - h.pct, seg = (ahora - h.t0) / 1000;
|
||
if (avance < 2 || seg < 8) return '';
|
||
const resta = seg * (100 - t.pct) / avance;
|
||
return resta < 60 ? '≈ ' + Math.max(5, Math.round(resta / 5) * 5) + ' s' : resta < 3600 ? '≈ ' + Math.round(resta / 60) + ' min' : '≈ ' + (resta / 3600).toFixed(1) + ' h';
|
||
}
|
||
function pintarTareas(){
|
||
const T = tareasDe(ESTADO);
|
||
const vivas = new Set(T.map(t => t.q + '|' + (t.raiz || ''))); Object.keys(ETA).forEach(k => { if (!vivas.has(k)) delete ETA[k]; });
|
||
const caja = $('#tareas'); caja.classList.toggle('on', T.length > 0);
|
||
caja.innerHTML = '<span class="tit">En curso</span>' + T.map((t, i) => `<div class="tarea run"><div class="w"><div class="q">${esc(t.q)}</div><div class="d" title="${esc(t.d)}">${esc(t.d)}</div><div class="b ${t.pct == null ? 'ind' : ''}"><i style="width:${t.pct == null ? 30 : t.pct}%"></i></div></div>${t.pct != null ? `<span class="pct">${t.pct} %<small>${etaDe(t)}</small></span>` : ''}${t.cancelar ? `<button data-i="${i}">Detener</button>` : ''}</div>`).join('');
|
||
$$('#tareas button').forEach(b => b.onclick = () => accion(b, async () => { await T[+b.dataset.i].cancelar(); await cargarEstado(); }));
|
||
}
|
||
|
||
// ---------------- barra lateral: carpetas y riel de pasos ----------------
|
||
const IR_PASO = ['carpeta', 'revisar', 'revisar', 'convertir', 'respaldar'];
|
||
const SUB_PASO = [null, 'nombrar', 'plan', null, null];
|
||
function pintarIndice(){
|
||
if (!ESTADO) return;
|
||
const act = raizActual(); const ocupadas = new Set(tareasDe(ESTADO).map(t => (t.raiz || '').toLowerCase()));
|
||
const v = vistaActual(); const aqui = {revisar: [1, 2], convertir: [3], respaldar: [4], carpeta: []}[v] || [];
|
||
$('#indice').innerHTML = ESTADO.raices.map(r => {
|
||
const run = ocupadas.has(r.clave.toLowerCase());
|
||
const e = run ? ['run', 'trabajando…'] : r.escaneo ? ['ok', `escaneada · ${gb(r.escaneo.bytes)}`] : ['', 'sin escanear'];
|
||
const disco = (r.ruta.match(/^[A-Za-z]:|^\\\\[^\\]+/) || [''])[0].toUpperCase();
|
||
let riel = '';
|
||
if (r.clave === act && CARPETA && CARPETA.raiz && CARPETA.raiz.toLowerCase() === r.clave.toLowerCase()) {
|
||
const P = pasosDe(CARPETA, r);
|
||
riel = `<div class="riel">${P.map((p, i) => `<div class="${p.e === 'hecho' ? 'ok' : p.e === 'curso' || p.e === 'listo' ? 'cur' : p.e} ${aqui.includes(i) ? 'aqui' : ''}" data-i="${i}" title="${esc(p.t)}: ${esc(p.d)}"><span class="pto ${p.e === 'hecho' ? 'ok' : p.e === 'curso' ? 'run' : p.e === 'listo' ? 'cur' : ''}"></span>${esc(p.t)}<small>${esc(p.corto || '')}</small></div>`).join('')}</div>`;
|
||
}
|
||
return `<div class="cl ${r.clave === act ? 'on' : ''}" data-r="${esc(r.clave)}" title="${esc(r.ruta)}" tabindex="0"><div class="n"><span>${esc(r.cliente)}</span><em>${esc(disco)}</em></div><div class="e"><span class="pto ${e[0]}"></span>${esc(e[1])}<button class="x" data-x="${esc(r.clave)}" title="Quitar de la lista (no borra nada del disco)" aria-label="Quitar de la lista">✕</button></div>${riel}</div>`;
|
||
}).join('') || '<div class="mut" style="font-size:12.5px">Ninguna todavía.</div>';
|
||
$$('#indice .cl').forEach(el => { const ir = () => { if ($('#raiz').value === el.dataset.r) { irA('carpeta'); return; } $('#raiz').value = el.dataset.r; $('#raiz').onchange(); }; el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } }; });
|
||
$$('#indice .x').forEach(el => el.onclick = e => { e.stopPropagation(); desconectar(el.dataset.x); });
|
||
$$('#indice .riel div').forEach(el => el.onclick = e => { e.stopPropagation(); const i = +el.dataset.i; if (el.classList.contains('bloq')) return; if (i === 0) { if (!CARPETA.escaneo) $('#btnEscanear').click(); else modal('Volver a escanear', '<p>Se vuelve a leer toda la carpeta (nombres, tamaños y fechas). No mueve nada. El plan calculado se descarta y se calcula de nuevo.</p>', () => $('#btnEscanear').click()); return; } irA(IR_PASO[i]); if (SUB_PASO[i]) subRev(SUB_PASO[i]); });
|
||
}
|
||
|
||
// ---------------- desconectar una carpeta ----------------
|
||
function desconectar(clave){
|
||
const r = (ESTADO.raices || []).find(x => x.clave === clave); if (!r) return;
|
||
const ocupada = tareasDe(ESTADO).some(t => (t.raiz || '').toLowerCase() === clave.toLowerCase());
|
||
if (ocupada) { aviso('Esa carpeta tiene una tarea en curso. Espera a que termine.', 'bad'); return; }
|
||
modal('Quitar «' + r.cliente + '» de la lista', `<p>La carpeta <span class="path">${esc(r.ruta)}</span> desaparece de la lista de la izquierda. <b>No se borra ni se mueve nada del disco</b>; puedes volver a soltarla cuando quieras.</p><label style="display:flex;gap:8px;align-items:flex-start;margin-top:10px;font-size:13px"><input type="checkbox" id="mDatos" style="margin-top:3px"><span>Olvidar también el inventario escaneado${r.escaneo ? ` (${num(r.escaneo.n)} archivos · ${gb(r.escaneo.bytes)})` : ''}. Si la vuelves a conectar habrá que escanearla de nuevo.</span></label>`, async () => {
|
||
const olvidar = $('#mDatos').checked;
|
||
const listo = async (n) => { aviso(n ? `Carpeta quitada de la lista; ${n} conversión${n > 1 ? 'es' : ''} descartada${n > 1 ? 's' : ''}.` : 'Carpeta desconectada.', 'ok'); await cargarEstado(); const s = $('#raiz'); if (s.value === clave) { s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); } else { pintarIndice(); cargarVista(); } };
|
||
try {
|
||
await api('/api/raiz/quitar', {raiz: clave, datos: olvidar});
|
||
await listo(0);
|
||
} catch (e) {
|
||
const c = e.datos && e.datos.cola;
|
||
if (!c) { aviso(e.message, 'bad', null, detalleDe(e)); return; }
|
||
// Conversiones sin terminar: se enseñan (cuáles, en qué estado) y, si ninguna está convirtiéndose ahora,
|
||
// se ofrece descartarlas y desconectar de una vez. Antes solo salía un globo sin decir cuáles.
|
||
apuntarAviso(e.message, c.archivos.join('\n'));
|
||
modal('No se pudo quitar «' + r.cliente + '»', `<p>${esc(e.message)}.</p><p><b>${c.n}</b> conversión${c.n > 1 ? 'es' : ''} sin terminar${c.por_estado.corriendo ? ` (${c.por_estado.corriendo} convirtiéndose ahora)` : ''}:</p><pre>${esc(c.archivos.join('\n'))}${c.n > c.archivos.length ? `\n… y ${c.n - c.archivos.length} más` : ''}</pre><p class="mut" style="font-size:12.5px">${c.en_curso ? 'Detén la cola en <b>Convertir a 10 bits</b> y vuelve a intentarlo.' : 'Descartarlas las quita de la cola sin tocar ningún archivo: los originales siguen donde están, y si vuelves a conectar la carpeta puedes encolarlas otra vez.'}</p>`,
|
||
c.en_curso ? null : async () => { try { await api('/api/raiz/quitar', {raiz: clave, datos: olvidar, descartar_cola: true}); await listo(c.n); } catch (e2) { aviso(e2.message, 'bad', null, detalleDe(e2)); } });
|
||
if (!c.en_curso) $('#modalSi').textContent = 'Descartar y quitar';
|
||
}
|
||
});
|
||
}
|
||
$('#btnDesconectar').onclick = () => desconectar(raizActual());
|
||
|
||
// ---------------- carpeta ----------------
|
||
const COLOR = {camara_raw:'#6b2f2a', stock_viejo:'#b8923a', stock_reciente:'#d9c58c', camara_video:'#3d5a73', exportados:'#7f8a94', proyectos:'#a9b0b8', basura:'#d3d6da', proxies:'#c4c9ce', audio:'#4f6b5a', celular:'#7a5c8a', fotos:'#8a6f4f', video_otro:'#94a3ad', imagenes:'#b5bec6', documentos:'#cfd5da', otros:'#e1e5e8', apartado:'#cfd4d8'};
|
||
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', apartado:'ya apartado'};
|
||
function pintarPortada(){
|
||
// las carpetas que el programa ya conoce, para entrar en una con un clic; con la última vez que se
|
||
// escaneó cada una. Es el «historial» de la portada: nada se abre solo.
|
||
const caja = $('#portadaCarpetas'); const rs = ESTADO.raices || [];
|
||
if (!rs.length) { caja.innerHTML = ''; $('#portadaAuto').innerHTML = ''; return; }
|
||
pintarAuto().catch(e => console.warn(e));
|
||
caja.innerHTML = '<h3 style="margin:22px 0 8px">Tus carpetas</h3><div class="portada">' + rs.map(r => {
|
||
const e = r.escaneo;
|
||
return `<div class="pc" data-r="${esc(r.clave)}" role="button" tabindex="0"><b>${esc(r.cliente)}</b><span class="path">${esc(r.ruta)}</span><span class="mut">${esc(ESTADO.tipos[r.tipo] || r.tipo)}</span><span class="mut">${e ? `${num(e.n)} archivos · ${gb(e.bytes)} · escaneada ${fecha(e.ts)}` : 'sin escanear todavía'}</span></div>`;
|
||
}).join('') + '</div>';
|
||
$$('#portadaCarpetas .pc').forEach(el => { const ir = () => { $('#raiz').value = el.dataset.r; $('#raiz').onchange(); }; el.onclick = ir; el.onkeydown = ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ir(); } }; });
|
||
}
|
||
async function cargarCarpeta(){
|
||
const raiz = raizActual();
|
||
$('#inicioVacio').style.display = raiz ? 'none' : 'block';
|
||
$('#inicioLleno').style.display = raiz ? '' : 'none';
|
||
if (!raiz) { pintarPortada(); return; }
|
||
const r = cfgRaiz();
|
||
CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raiz));
|
||
$('#cabTitulo').textContent = r.cliente;
|
||
const e = CARPETA.escaneo;
|
||
$('#cabSub').innerHTML = `<span class="path">${esc(r.ruta)}</span> · ${esc(ESTADO.tipos[r.tipo] || r.tipo)}${e ? ` · ${num(e.n)} archivos · ${gb(e.bytes)} · escaneada ${fecha(e.ts)}` : ' · sin escanear'}`;
|
||
pintarIndice(); pintarSiguiente(pasosDe(CARPETA, r));
|
||
pintarAcciones().catch(e => console.warn(e));
|
||
const d = CARPETA.diagnostico;
|
||
if (!d) { $('#pesoBarra').innerHTML = ''; $('#pesoLinea').innerHTML = ''; $('#inicioDiag').innerHTML = '<span class="mut">Escanea la carpeta para ver qué hay.</span>'; }
|
||
else {
|
||
$('#pesoBarra').innerHTML = d.grupos.map(g => `<i style="width:${Math.max(g.pct, 0.4)}%;background:${COLOR[g.clave] || '#ccc'}" title="${esc(g.titulo)} · ${gb(g.bytes)} · ${num(g.n)} archivos"></i>`).join('');
|
||
$('#pesoLinea').innerHTML = d.grupos.slice(0, 5).map(g => `<b>${gb(g.bytes)}</b> ${esc(NOMBRE_CORTO[g.clave] || g.titulo)}`).join(' · ');
|
||
$('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : '');
|
||
}
|
||
const lotes = CARPETA.lotes || [];
|
||
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${cuentaLote(l, true)}${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-') || l.lote.startsWith('tanda-')) ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
|
||
$$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
|
||
}
|
||
// ---------------- lo que se puede hacer en esta carpeta ----------------
|
||
// Una sola lista, en orden, con la cifra de cada cosa y un botón. Sustituye a repartir el trabajo
|
||
// entre pestañas dentro de pestañas: el usuario ve de un vistazo qué falta y cuánto pesa.
|
||
const IR_ACCION = {
|
||
plan: p => { irA('revisar'); if (PLAN && p) { PLAN_PASO = p; PLAN_EJ = 0; pintarPlan(); } subRev('plan'); if (!PLAN) $('#btnPlan').click(); },
|
||
nombrar: () => { irA('revisar'); subRev('nombrar'); },
|
||
stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); },
|
||
vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); },
|
||
mapa: () => irA('mapa'),
|
||
repetidos: () => { irA('revisar'); subRev('hay'); const d = $('#dups'); if (d) d.scrollIntoView({block: 'center', behavior: 'smooth'}); },
|
||
convertir: () => { irA('convertir'); $('#btnBuscarClips').click(); },
|
||
respaldar: () => irA('respaldar'),
|
||
escanear: () => $('#btnEscanear').click(),
|
||
entrada: () => { modal('Repartir la bandeja de entrada', '<p>La app vuelve a leer la carpeta y lleva lo que hay en <span class="path">_Entrada</span> a su mes, a su día de rodaje y a su cámara.</p><p>Solo mueve lo que da por seguro: lo dudoso se queda en la bandeja para que lo mires. Nada se borra y se deshace desde el Historial.</p>', repartirEntrada); $('#modalSi').textContent = 'Repartir'; },
|
||
};
|
||
const repartirEntrada = seguro(async () => {
|
||
const raiz = raizActual();
|
||
aviso('Leyendo la carpeta y repartiendo la bandeja…', 'ok');
|
||
const r = await api('/api/entrada/ordenar', {raiz});
|
||
PLAN = null; MAPA = null;
|
||
aviso(`${r.movimientos} movimiento${r.movimientos !== 1 ? 's' : ''} hecho${r.movimientos !== 1 ? 's' : ''}.` + (r.quedan ? ` Quedan ${num(r.quedan)} archivo${r.quedan !== 1 ? 's' : ''} en la bandeja: la app no supo dónde ponerlos, míralos en «Qué va a pasar».` : ' La bandeja quedó vacía.') + (r.errores.length ? ` ${r.errores.length} no se pudieron mover.` : ''), r.errores.length ? 'bad' : 'ok', r.lote ? {texto: 'Deshacer esto', ir: () => deshacer(r.lote)} : null, r.errores.join('\n'));
|
||
await cargarEstado(); cargarVista();
|
||
});
|
||
async function pintarAcciones(){
|
||
const raiz = raizActual(); if (!raiz) return;
|
||
const d = await api('/api/carpeta/acciones?raiz=' + encodeURIComponent(raiz));
|
||
if (raizActual() !== raiz) return; // cambió de carpeta mientras llegaba
|
||
ACCIONES = d.acciones || [];
|
||
const quedan = ACCIONES.filter(a => a.estado === 'toca').length;
|
||
const auto = d.automatico || [], asis = d.asistente;
|
||
const corriendo = asis && asis.activo;
|
||
const cab = corriendo
|
||
? `<div class="cabsec">Trabajando en esta carpeta<span class="sep"></span><span class="mut" style="text-transform:none;letter-spacing:0">${esc(NOMBRE_PASO[asis.paso] || asis.paso || '')} · ${asis.hechas || 0} de ${asis.total || 0}</span></div>`
|
||
: `<div class="cabsec">${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día' + (d.ultimo ? ` · último cambio ${esc(fechaCorta(d.ultimo.ts))}` : '')}${auto.length > 1 ? '<span class="sep"></span><button class="p" id="btnHacerTodo" style="text-transform:none;letter-spacing:0">Hacer las ' + auto.length + ' de una vez</button>' : ''}</div>`;
|
||
$('#acciones').innerHTML = cab +
|
||
ACCIONES.map((a, i) => `<div class="ac ${a.estado}"><span class="ico">${a.calculando ? '<span class="spin"></span>' : a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}</span><div class="tx"><b>${esc(a.titulo)}</b><div class="q">${esc(a.que)}</div>${a.detalle ? `<div class="d">${esc(a.detalle)}</div>` : ''}</div><div class="der">${a.cifra ? `<span class="cifra">${esc(a.cifra)}</span>` : ''}${a.boton ? `<button class="${a.estado === 'espera' ? 'lnk' : 'p'}" data-a="${i}">${esc(a.boton)}</button>` : ''}</div></div>`).join('');
|
||
$$('#acciones [data-a]').forEach(b => b.onclick = () => { const a = ACCIONES[+b.dataset.a]; (IR_ACCION[a.ir] || (() => {}))(a.paso); });
|
||
// se acaba de mover algo y el programa está comprobando qué queda: se vuelve a preguntar en un momento
|
||
if (d.calculando) setTimeout(() => { if (raizActual() === raiz && vistaActual() === 'carpeta') cargarCarpeta().catch(() => {}); }, 1500);
|
||
const bt = $('#btnHacerTodo');
|
||
if (bt) bt.onclick = () => modal('Hacer todo lo seguro de una vez',
|
||
`<p>La app va a hacer seguidas las <b>${auto.length}</b> cosas de la lista que no necesitan que elijas nada, en orden y sin preguntar.</p>
|
||
<p>Solo mueve lo que considera seguro: <b>lo dudoso se queda como está</b> para que lo mires tú. Todo se queda dentro de <span class="path">${esc(raiz)}</span>, nada se borra, y cada cambio se deshace desde el Historial.</p>
|
||
<p class="mut" style="font-size:13px">No entran aquí convertir los clips ni copiar el respaldo: esos tienen su propio botón porque hay que elegir cosas.</p>`,
|
||
() => accion(bt, async () => {
|
||
await api('/api/acciones/hacer', {raiz});
|
||
aviso('En marcha. El avance está en la barra de abajo y aquí arriba.', 'ok');
|
||
await cargarEstado(); await pintarAcciones();
|
||
}));
|
||
}
|
||
// «212 clips · 676 GB → 24 GB · nota 96» para una tanda del convertidor; «N movimientos» para lo demás
|
||
function cuentaLote(l, corto){
|
||
if (l.tanda) return `${l.tanda.clips} clip${l.tanda.clips !== 1 ? 's' : ''} · ${gb(l.tanda.bytes_in)} → ${gb(l.tanda.bytes_out)}${l.tanda.nota ? ` · nota ${l.tanda.nota}` : ''}${l.tanda.reemplazados ? ` · ${l.tanda.reemplazados} reemplazado${l.tanda.reemplazados !== 1 ? 's' : ''}` : ''}`;
|
||
return corto ? `${l.movimientos} mov.` : `${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}`;
|
||
}
|
||
function queSeHizo(lote){
|
||
if (!lote) return 'Cambio';
|
||
if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por la versión convertida';
|
||
if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo';
|
||
if (lote.startsWith('descartados-')) return 'Apartó conversiones que no compensaban';
|
||
if (lote.startsWith('tanda-')) return 'Convirtió una tanda de clips';
|
||
if (lote.startsWith('dup-')) return 'Juntó material repetido';
|
||
if (lote.startsWith('vacias-')) return 'Borró carpetas vacías';
|
||
if (lote.startsWith('pieza-')) return 'Creó una pieza nueva';
|
||
if (lote.startsWith('entrada-')) return 'Trajo material a la bandeja de entrada';
|
||
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') && !l.lote.startsWith('descartados-') && !l.lote.startsWith('tanda-'));
|
||
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 && C.plan.marcadas ? {e:'listo', t:'Ordenar', d:`Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`, corto: `${C.plan.marcadas} mov.`}
|
||
// un plan sin nada marcado es una carpeta en orden (quizá con dudosos): hecho, no «listo para aplicar»
|
||
: C.plan ? {e:'hecho', t:'Ordenar', d: (lotes.length ? `Último cambio ${fecha(lotes[0].ts)} · ${lotes[0].movimientos} mov.` : 'Nada que mover') + (C.plan.filas ? ` · ${C.plan.filas} dudoso${C.plan.filas > 1 ? 's' : ''} sin marcar` : ''), corto: lotes.length ? fechaCorta(lotes[0].ts) : 'ok'}
|
||
: lotes.length ? {e:'hecho', t:'Ordenar', d:`Último cambio ${fecha(lotes[0].ts)} · ${lotes[0].movimientos} mov.`, corto: fechaCorta(lotes[0].ts)} : {e:'pend', t:'Ordenar', d:'Calcula qué va a pasar y aplica', corto: ''});
|
||
const cv = C.convertir || {}; const tr = cv.trabajos || {}; const n = k => (tr[k] || {}).n || 0;
|
||
const activos = n('pendiente') + n('corriendo'), listos = n('listo'), verif = n('verificado'), reemp = n('reemplazado');
|
||
const anaRun = cv.analisis && cv.analisis.estado === 'corriendo';
|
||
const conError = n('error') + n('cancelado');
|
||
// Las conversiones sin terminar o con error se enseñan SIEMPRE, aunque la carpeta ya no tenga material de
|
||
// cámara (disco desconectado, material movido): antes el paso decía «nada» mientras la cola bloqueaba Desconectar.
|
||
P.push(!esc ? {e:'bloq', t:'Convertir', d:'Después de escanear'}
|
||
: anaRun ? {e:'curso', t:'Convertir', d:`Leyendo clips ${cv.analisis.hechos} de ${cv.analisis.total}`, corto: 'leyendo'}
|
||
: cv.cola_activa && activos ? {e:'curso', t:'Convertir', d:`${activos} clip${activos > 1 ? 's' : ''} en cola`, corto: `${activos} en cola`}
|
||
: activos ? {e:'listo', t:'Convertir', d:`${activos} conversión${activos > 1 ? 'es' : ''} sin terminar, cola parada`, corto: `${activos} parada${activos > 1 ? 's' : ''}`}
|
||
: conError ? {e:'listo', t:'Convertir', d:`${conError} conversión${conError > 1 ? 'es' : ''} con error`, corto: `${conError} con error`}
|
||
: !cv.camara_bytes ? {e:'hecho', t:'Convertir', d:'No hay material de cámara', corto: 'nada'}
|
||
: listos ? {e:'listo', t:'Convertir', d:`${listos} convertido${listos > 1 ? 's' : ''} por verificar`, corto: `${listos} sin verificar`}
|
||
: verif ? {e:'listo', t:'Convertir', d:`${verif} verificado${verif > 1 ? 's' : ''} por reemplazar`, corto: `${verif} por reemplazar`}
|
||
: reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya convertido${reemp > 1 ? 's' : ''}`, corto: `${reemp} hechos`}
|
||
: {e:'pend', t:'Convertir', d:`${gb(cv.camara_bytes)} de cámara${cv.raw_bytes ? ' · ' + gb(cv.raw_bytes) + ' BRAW' : ''}`, corto: gb(cv.camara_bytes)});
|
||
const rp = C.respaldo || {};
|
||
P.push(!esc ? {e:'bloq', t:'Respaldar', d:'Después de escanear'} : rp.estado === 'corriendo' ? {e:'curso', t:'Respaldar', d:'Copiando…', corto: 'copiando'}
|
||
: rp.plan ? {e:'listo', t:'Respaldar', d:`${num(rp.plan.n)} archivos · ${gb(rp.plan.bytes)} listos para copiar`, corto: gb(rp.plan.bytes)}
|
||
: {e:'pend', t:'Respaldar', d: rp.destino ? `A ${rp.destino}` : 'Sin destino', corto: ''});
|
||
return P;
|
||
}
|
||
function pintarSiguiente(P){
|
||
const C = CARPETA; const s = {t:'', p:'', acts:[]};
|
||
const b = (txt, fn, p) => s.acts.push({txt, fn, p});
|
||
if (P[0].e === 'curso') { s.t = 'Escaneando la carpeta'; s.p = 'Está leyendo nombres, tamaños y fechas. No toca nada. Cuando termine, aquí aparece el diagnóstico.'; }
|
||
else if (P[0].e === 'pend') { s.t = 'Empieza por escanear'; s.p = 'La app lee los nombres, tamaños y fechas de todos los archivos. No mueve nada. En una carpeta grande del NAS tarda unos minutos.'; b('Escanear ahora', () => $('#btnEscanear').click(), true); }
|
||
else if (P[2].e === 'curso') { s.t = 'El asistente está trabajando'; s.p = 'Puedes seguir el avance en la barra de abajo.'; }
|
||
else if (P[1].e === 'listo' && !C.plan && P[2].e !== 'hecho') { const n = C.sin_nombre_n; s.t = `Antes de ordenar: nombra ${n} carpeta${n > 1 ? 's' : ''}`; s.p = `La app no sabe qué son ${C.sin_nombre.slice(0, 4).map(x => '«' + x.ejemplo + '»').join(', ')}${n > 4 ? ' y otras' : ''}. Si le dices qué es cada una, las ordena bien y se lo aprende para todos los discos. Si prefieres, sáltalo: quedarán como «dudoso» y no se moverán.`; b('Nombrar las carpetas', () => { irA('revisar'); subRev('nombrar'); }, true); b('Saltar y calcular', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }); }
|
||
else if (C.plan && C.plan.marcadas) { s.t = `Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`; s.p = `Todo se mueve dentro de la misma carpeta y nada se borra. ${C.plan.baja ? C.plan.baja + ' son dudosos y están sin marcar. ' : ''}Revisa la lista y aplica; se puede deshacer desde Historial.`; b('Revisar y aplicar', () => { irA('revisar'); subRev('plan'); }, true); }
|
||
else if (P[2].e === 'pend') { s.t = 'Siguiente: ordenar la carpeta'; s.p = 'Calcula qué va a pasar (basura y proxies a _Para borrar, grabaciones por mes y cámara, piezas numeradas) y aplícalo con un clic. O deja que el asistente te pregunte paso a paso.'; b('Calcular qué va a pasar', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }, true); }
|
||
else if (P[3].e === 'listo') { const d = P[3].d; s.t = 'Siguiente: ' + (d.includes('verificar') ? 'verificar los clips convertidos' : d.includes('reemplazar') ? 'reemplazar los originales' : 'retomar la conversión'); s.p = d.includes('verificar') ? 'Verificar comprueba códec, 10 bits y duración. Después puedes reemplazar los originales (quedan en _Para borrar/originales).' : d.includes('reemplazar') ? 'Los originales se apartan a _Para borrar/originales y el MP4 10 bits queda en su lugar. Nada se borra.' : 'Hay clips en cola con la conversión parada.'; b('Ir a Convertir', () => irA('convertir'), true); }
|
||
else if (P[3].e === 'curso') { s.t = 'Convirtiendo clips'; s.p = 'Sigue en segundo plano. El avance está en la barra de abajo; cuando termine, toca reemplazar.'; b('Ver la cola', () => irA('convertir')); }
|
||
else if (P[3].e === 'pend') { const cv = C.convertir; s.t = 'Siguiente: aligerar el material de cámara'; s.p = `Hay ${gb(cv.camara_bytes)} de clips de cámara. Los de proyectos ya entregados pueden pasar a MP4 10 bits, unas 10 veces más ligeros. 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>';
|
||
}
|
||
// repetidos: la lista entera, comparando también los clips que se llaman igual y pesan distinto
|
||
const TIPO_DUP = {identico: 'idénticos', version: 'dos formatos', calidad: 'dos calidades'};
|
||
let DUPS = null;
|
||
$('#btnComparar').onclick = () => accion($('#btnComparar'), async () => {
|
||
const raiz = raizActual();
|
||
DUPS = await api('/api/duplicados?calidades=1&raiz=' + encodeURIComponent(raiz)); DUPS.raiz = raiz;
|
||
const rel = p => p.toLowerCase().startsWith(raiz.toLowerCase()) ? p.slice(raiz.length).replace(/^[\\/]/, '') : p;
|
||
$('#dups').innerHTML = DUPS.grupos.length ? DUPS.grupos.slice(0, 80).map(d => `<div style="margin-bottom:8px"><b>${esc(d.nombre)}</b> <span class="mut">${TIPO_DUP[d.tipo] || d.tipo} · ${gb(d.bytes)} de más${d.detalle ? ' · ' + esc(d.detalle) : ''}</span><ul class="ejemplos" style="margin:2px 0 0 16px;padding:0"><li>se queda: ${esc(rel(d.queda))}</li>${d.mueven.map(r => `<li>→ Duplicados: ${esc(rel(r))}</li>`).join('')}</ul></div>`).join('') + (DUPS.grupos.length > 80 ? `<div class="mut">… y ${DUPS.grupos.length - 80} grupos más</div>` : '') : '<span class="mut">Nada repetido: los clips que se llaman igual duran distinto, así que son clips distintos.</span>';
|
||
$('#dupsResumen').textContent = DUPS.grupos.length ? `${DUPS.n} de más · ${gb(DUPS.bytes)} · ${DUPS.identicos} idénticos, ${DUPS.versiones} en dos formatos, ${DUPS.calidades} con dos calidades` : '';
|
||
$('#btnJuntar').disabled = !DUPS.grupos.length;
|
||
});
|
||
$('#btnJuntar').onclick = () => { if (!DUPS || DUPS.raiz !== raizActual()) return; modal('Juntar material repetido', `<p>De cada grupo se queda un archivo donde está, y los demás (<b>${DUPS.n}</b>, ${gb(DUPS.bytes)}) van a una subcarpeta <span class="path">Duplicados</span> a su lado, para mirarlos a mano.</p><p><b>No se borra nada.</b> Las copias idénticas se comparan por contenido antes de moverlas, y de dos calidades del mismo clip se queda la de más imagen. Se deshace desde el Historial.</p>`, () => accion($('#btnJuntar'), async () => {
|
||
const r = await api('/api/duplicados/apartar', {raiz: raizActual()});
|
||
DUPS = null; MAPA = null; PLAN = null; $('#btnJuntar').disabled = true; $('#dupsResumen').textContent = '';
|
||
aviso(`${r.movimientos} archivo${r.movimientos !== 1 ? 's' : ''} a Duplicados.` + (r.errores.length ? ` ${r.errores.length} se dejaron donde estaban.` : ''), r.errores.length ? 'bad' : 'ok', r.lote ? {texto: 'Deshacer esto', ir: () => deshacer(r.lote)} : null, r.errores.join('\n'));
|
||
await cargarEstado(); cargarVista();
|
||
})); $('#modalSi').textContent = 'Juntar'; };
|
||
|
||
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 PARECE = {CONTENEDOR: 'un cajón con cosas de varias fechas', PIEZA: 'una pieza montada'};
|
||
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 rows = VOC.filter(t => (!f || t.token.includes(f) || (t.rutas || []).join(' ').toLowerCase().includes(f)) && (!solo || !t.cubeta)).sort((a, b) => (!!a.cubeta - !!b.cubeta) || ((b.sug_conf === 'alta') - (a.sug_conf === 'alta')) || b.bytes - a.bytes);
|
||
const sin = VOC.filter(t => !t.cubeta && !t.dentro_pieza).length;
|
||
const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta');
|
||
$('#vocResumen').textContent = `${rows.length} carpetas · ${sin} sin reconocer${seguras.length ? ` · ${seguras.length} con sugerencia segura` : ''}`;
|
||
const bd = $('#badgeNombrar'); bd.textContent = sin; bd.classList.toggle('cero', !sin);
|
||
bloquear('btnAceptarSug', seguras.length ? '' : 'x'); $('#btnAceptarSug').textContent = seguras.length ? `Aceptar ${seguras.length} sugerencia${seguras.length > 1 ? 's' : ''} segura${seguras.length > 1 ? 's' : ''}` : 'Aceptar las sugerencias seguras';
|
||
$('#nombrar').innerHTML = rows.slice(0, 300).map(t => `<li><span><b title="${esc((t.rutas || []).join('\n'))}">${esc(t.ejemplo)}${t.aprendido ? ' <span class="tag alta">aprendido</span>' : ''}${t.n > 1 ? ` <span class="mut" style="font-weight:500">· ${t.n} carpetas con este nombre</span>` : ''}</b><span class="r">${esc((t.rutas || []).slice(0, 2).join(' · '))}</span>${t.contenido ? `<span class="r">Dentro: ${esc(t.contenido)}</span>` : ''}${t.nota ? `<span class="nt">Ojo: ${esc(t.nota)}.</span>` : ''}${!t.cubeta && t.sugerencia ? `<span class="sg">Parece <b>${esc(PARECE[t.sugerencia] || NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</b> (${SEG_TXT[t.sug_conf] || t.sug_conf}): ${esc(t.por_que)}</span>` : !t.cubeta ? `<span class="sg" style="color:var(--mut)">Sin pista: elige qué es, o déjala y no se moverá.</span>` : ''}</span><span class="acts">${!t.cubeta && t.sugerencia ? `<button class="p" data-si="${esc(t.token)}" data-cub="${esc(t.sugerencia)}" title="${t.sugerencia === 'CONTENEDOR' ? 'La app la abre y ordena lo de dentro, cada cosa a su mes' : ''}">${esc(t.sugerencia === 'CONTENEDOR' ? 'Sí, abrirla' : 'Sí, es ' + (NOMBRE_CUBETA[t.sugerencia] || t.sugerencia))}</button>` : ''}<select data-tok="${esc(t.token)}" aria-label="qué es">${opcionesCubeta(t.cubeta)}</select></span><small>${gb(t.bytes)}</small></li>`).join('') + (rows.length > 300 ? `<li class="mut">… y ${rows.length - 300} más: usa el buscador</li>` : '') || (sin ? '<li class="mut">Nada con ese filtro.</li>' : '<li class="ok">Todas las carpetas están reconocidas.</li>');
|
||
$$('#nombrar select').forEach(s => s.onchange = () => nombrarToken(s.dataset.tok, s.value));
|
||
$$('#nombrar [data-si]').forEach(b => b.onclick = () => nombrarToken(b.dataset.si, b.dataset.cub));
|
||
}
|
||
$('#btnAceptarSug').onclick = () => accion($('#btnAceptarSug'), async () => {
|
||
const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta');
|
||
for (const t of seguras) { await api('/api/diccionario', {token: t.token, cubeta: t.sugerencia}); t.cubeta = t.sugerencia; t.aprendido = true; }
|
||
PLAN = null; estadoPlan(); if (CARPETA) { CARPETA.sin_nombre_n = VOC.filter(x => !x.cubeta && !x.dentro_pieza).length; pintarIndice(); }
|
||
pintarNombrar(); aviso(`${seguras.length} carpetas nombradas. Calcula el plan para verlas ordenadas.`, 'ok');
|
||
});
|
||
const nombrarToken = seguro(async (tok, cub) => {
|
||
await api('/api/diccionario', {token: tok, cubeta: cub});
|
||
const t = VOC.find(x => x.token === tok); if (t) { t.cubeta = cub; t.aprendido = !!cub; t.motivo = 'lo dijiste tú'; }
|
||
PLAN = null; estadoPlan(); if (CARPETA) { CARPETA.sin_nombre_n = VOC.filter(x => !x.cubeta && !x.dentro_pieza).length; pintarIndice(); }
|
||
pintarNombrar();
|
||
if (cub) aviso(`«${t ? t.ejemplo : tok}» ahora es ${NOMBRE_CUBETA[cub] || cub}. Calcula el plan de nuevo para verlo.`, 'ok');
|
||
});
|
||
$('#filtroVoc').oninput = pintarNombrar; $('#soloSin').onchange = pintarNombrar;
|
||
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 (un ejemplo animado, y la lista de lo demás) ----------------
|
||
// La pestaña explica el plan con UNA carpeta (el ejemplo, que se ve pasar de donde está a donde va) y
|
||
// debajo lista lo demás que pasa en ese paso. Un clic aplica todo lo marcado; lo que no va solo
|
||
// (cosméticos, dudosos) queda aparte, contado y con su porqué, para que nadie tenga que ir uno por uno.
|
||
const PASOS_PLAN = [
|
||
{k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema y proxies van a «_Para borrar». Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'},
|
||
{k: 'fechas', t: 'Ordenar por año y mes', q: 'Grabaciones y piezas, en la carpeta del mes en que se grabaron.', vacio: 'Las fechas ya están en orden.'},
|
||
{k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música y marca, cada cosa a su carpeta.', vacio: 'Nada más que ordenar.'},
|
||
];
|
||
const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'};
|
||
let PLAN_PASO = null, PLAN_EJ = 0, PLAN_VER_OPC = false, PLAN_POR_QUE = false, PLAN_AVISOS = false, ACCIONES = [];
|
||
const pasoDe = r => r.paso || 'resto';
|
||
const filasPaso = k => PLAN ? PLAN.filas.filter(r => pasoDe(r) === k) : [];
|
||
const trozos = p => (p || '').split(/[\\/]/).filter(Boolean);
|
||
const ultimo = p => trozos(p).pop() || p || '';
|
||
const padreDe = p => trozos(p).slice(0, -1).join('\\');
|
||
function estadoPlan(){
|
||
const hay = !!PLAN;
|
||
$('#btnArbol').disabled = !hay;
|
||
const seg = hay ? PLAN.filas.filter(r => r.marcado) : [];
|
||
bloquear('btnAplicarSeguros', hay ? (seg.length ? '' : 'x') : 'x');
|
||
$('#btnAplicarSeguros').textContent = seg.length ? (seg.length === 1 ? 'Aplicar el cambio' : `Aplicar los ${seg.length} cambios`) : hay && PLAN.filas.length ? 'Nada marcado para aplicar' : 'Aplicar';
|
||
$('#planUnClic').style.display = seg.length > 1 ? '' : 'none';
|
||
$('#btnPlan').textContent = hay ? 'Calcular de nuevo' : 'Calcular qué va a pasar';
|
||
$('#btnPlan').classList.toggle('p', !hay);
|
||
if (CARPETA) { CARPETA.plan = hay ? {filas: PLAN.filas.length, marcadas: seg.length, baja: PLAN.filas.filter(r => r.confianza === 'baja').length} : null; pintarIndice(); }
|
||
}
|
||
async function calcularPlan(){
|
||
$('#planIntro').innerHTML = '<span class="spin"></span> mirando la carpeta…';
|
||
PLAN = await api('/api/plan?raiz=' + encodeURIComponent(raizActual()));
|
||
PLAN_EJ = 0; PLAN_VER_OPC = false;
|
||
pintarPlan();
|
||
}
|
||
$('#btnPlan').onclick = () => { $('#planAvance').innerHTML = ''; accion($('#btnPlan'), calcularPlan); };
|
||
function motivoLlano(m){
|
||
return (m || '').replace(/diccionario: palabra «([^»]+)»/g, 'por la palabra «$1» del nombre').replace(/diccionario: contiene «([^»]+)»/g, 'porque el nombre contiene «$1»').replace(/diccionario: «([^»]+)»/g, 'por su nombre («$1»)').replace(/regla aprendida/g, 'lo dijiste tú la vez pasada').replace(/^no reconocida: /, 'la app no reconoce el nombre: ').replace(/\(fecha de modificación\)/g, '(por la fecha de modificación: puede fallar)').replace(/^cosmético: nombre de pieza a «NN- Título» \(sin marcar por defecto\)$/, 'solo cambia el nombre a la forma «NN- Título»: da igual hacerlo o no');
|
||
}
|
||
function fraseDe(r){
|
||
const nom = x => `«${esc(ultimo(x))}»`;
|
||
if (r.origen_rel === '(toda la raíz)') return `${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'} repartidos por la carpeta se apartan en «_Para borrar»`;
|
||
if (r.accion === 'papelera') return `${nom(r.origen_rel)} se aparta en «_Para borrar»`;
|
||
if (r.accion === 'renombrar') return `${nom(r.origen_rel)} pasa a llamarse ${nom(r.destino_rel)}`;
|
||
if (r.archivos) return `${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''} de ${nom(r.origen_rel)} van a «${esc(r.destino_rel)}»`;
|
||
return `${nom(r.origen_rel)} se mueve a «${esc(r.destino_rel)}»`;
|
||
}
|
||
function pasoPorDefecto(){
|
||
const con = PASOS_PLAN.find(p => filasPaso(p.k).some(r => r.marcado)) || PASOS_PLAN.find(p => filasPaso(p.k).length);
|
||
return con ? con.k : null;
|
||
}
|
||
// «43 cosméticos y 5 dudosos»: por qué hay cambios sin marcar
|
||
function resumenOpc(rs){
|
||
const cos = rs.filter(r => r.cosmetico).length, dud = rs.filter(r => !r.cosmetico && r.confianza === 'baja').length, otros = rs.length - cos - dud;
|
||
return [cos ? `${cos} cosmético${cos > 1 ? 's' : ''}` : '', dud ? `${dud} dudoso${dud > 1 ? 's' : ''}` : '', otros ? `${otros} que desmarcaste` : ''].filter(Boolean).join(', ').replace(/, ([^,]*)$/, ' y $1');
|
||
}
|
||
function pintarPlan(){
|
||
if (!PLAN) { estadoPlan(); return; }
|
||
if (!PLAN_PASO || !filasPaso(PLAN_PASO).length) PLAN_PASO = pasoPorDefecto();
|
||
pintarCabPlan();
|
||
const avs = PLAN.avisos || [];
|
||
$('#planAvisos').innerHTML = avs.length ? `<details class="hint warn avisos" ${PLAN_AVISOS ? 'open' : ''}><summary><b>${avs.length === 1 ? 'Una cosa que conviene mirar antes de aplicar' : avs.length + ' cosas que conviene mirar antes de aplicar'}</b></summary><ul>${avs.map(a => `<li>${esc(a)}</li>`).join('')}</ul></details>` : '';
|
||
const dt = $('#planAvisos details'); if (dt) dt.ontoggle = () => PLAN_AVISOS = dt.open;
|
||
pintarEjemplo();
|
||
pintarLista();
|
||
estadoPlan();
|
||
}
|
||
// la frase de arriba y las tres fichas: cuánto hace cada paso y cuánto queda sin marcar
|
||
function pintarCabPlan(){
|
||
const tot = PLAN.filas.length, marc = PLAN.filas.filter(r => r.marcado), sin = PLAN.filas.filter(r => !r.marcado);
|
||
$('#planIntro').innerHTML = !tot ? `<b class="grande">La carpeta ya está en orden.</b> No hay nada que apartar ni mover.`
|
||
: !marc.length ? `<b class="grande">Lo seguro ya está hecho.</b> Quedan ${sin.length} cambio${sin.length > 1 ? 's' : ''} que la app no hace sola (${resumenOpc(sin)}). Están abajo, por si quieres marcarlos.`
|
||
: `<b class="grande">La app va a hacer ${marc.length} cambio${marc.length > 1 ? 's' : ''}</b> (${gb(marc.reduce((a, r) => a + r.bytes, 0))}) dentro de <span class="path">${esc(PLAN.raiz)}</span>. Nada se borra, y se deshace desde Historial.${sin.length ? ` Otros ${sin.length} quedan sin marcar (${resumenOpc(sin)}): están abajo, por si los quieres.` : ''}`;
|
||
$('#planPasos').innerHTML = PASOS_PLAN.map((p, i) => {
|
||
const rs = filasPaso(p.k), m = rs.filter(r => r.marcado), vacio = !rs.length, on = PLAN_PASO === p.k;
|
||
return `<div class="ficha ${vacio ? 'vacio' : ''} ${on ? 'on' : ''}" data-paso="${p.k}" ${vacio ? '' : `tabindex="0" role="button" aria-pressed="${on}"`}><span class="num">${vacio ? '✓' : i + 1}</span><div class="tx"><b>${p.t}</b><div class="q">${vacio ? p.vacio : p.q}</div>${vacio ? '' : `<div class="cifra">${m.length ? `<b>${m.length}</b> cambio${m.length > 1 ? 's' : ''} · ${gb(m.reduce((a, r) => a + r.bytes, 0))}` : '<b>0</b> marcados'}${rs.length > m.length ? ` <span class="mut">· ${rs.length - m.length} sin marcar</span>` : ''}</div>`}</div>${vacio || !m.length ? '' : `<button class="lnk" data-aplicar="${p.k}" title="Aplicar solo lo marcado de este paso">solo este paso</button>`}</div>`;
|
||
}).join('');
|
||
$$('#planPasos .ficha:not(.vacio)').forEach(f => { const ir = () => { if (PLAN_PASO !== f.dataset.paso) { PLAN_PASO = f.dataset.paso; PLAN_EJ = 0; PLAN_VER_OPC = false; pintarPlan(); } }; f.onclick = e => { if (!e.target.closest('button')) ir(); }; f.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } }; });
|
||
$$('#planPasos [data-aplicar]').forEach(b => b.onclick = () => aplicarPaso(b.dataset.aplicar, b));
|
||
}
|
||
// ---- el ejemplo: una fila del paso, dibujada como dos ramas de carpetas, y lo que se mueve volando de una a otra
|
||
function ejemplosDe(k){ return filasPaso(k).slice().sort((a, b) => (b.marcado - a.marcado) || ((b.accion !== 'renombrar') - (a.accion !== 'renombrar')) || (b.bytes - a.bytes) || (b.n - a.n)); }
|
||
function ejemploActual(){ const ej = ejemplosDe(PLAN_PASO); return ej.length ? ej[PLAN_EJ % ej.length] : null; }
|
||
function ramasDe(r){
|
||
const n = r.archivos ? r.archivos.length : 0, arch = `${n} archivo${n > 1 ? 's' : ''}`;
|
||
if (r.archivos && r.origen_rel === '(toda la raíz)') return {antes: [], despues: trozos(r.destino_rel), movil: {chip: true, antes: `${arch} de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}, por toda la carpeta`, despues: arch}};
|
||
if (r.archivos) return {antes: trozos(r.origen_rel), despues: trozos(r.destino_rel), movil: {chip: true, antes: arch, despues: arch}};
|
||
return {antes: trozos(padreDe(r.origen_rel)), despues: trozos(padreDe(r.destino_rel)), movil: {chip: false, antes: ultimo(r.origen_rel), despues: ultimo(r.destino_rel)}};
|
||
}
|
||
function ramaHtml(segs, movil, cual){
|
||
let h = `<div class="nivel raiz"><i class="fo"></i><span>${esc(ultimo(PLAN.raiz))}</span></div>`;
|
||
segs.forEach((s, i) => h += `<div class="nivel" style="--n:${i + 1}"><i class="fo"></i><span>${esc(s)}</span></div>`);
|
||
return h + `<div class="nivel" style="--n:${segs.length + 1}"><span class="mv ${movil.chip ? 'chip' : ''}">${movil.chip ? '' : '<i class="fo"></i>'}<span>${esc(movil[cual])}</span></span></div>`;
|
||
}
|
||
function pintarEjemplo(){
|
||
const caja = $('#planEjemplo'), ej = ejemplosDe(PLAN_PASO), r = ejemploActual();
|
||
if (!r) { caja.innerHTML = ''; return; }
|
||
const ramas = ramasDe(r), otros = filasPaso(PLAN_PASO).filter(x => x.marcado && x !== r).length, p = PASOS_PLAN.find(x => x.k === PLAN_PASO);
|
||
caja.innerHTML = `<div class="ejemplo" id="ejCaja"><div class="ejcab"><span class="cabsec" style="margin:0">${esc(p.t)} · con un ejemplo</span><span class="sep"></span>${ej.length > 1 ? `<span class="mut" style="font-size:12px">${(PLAN_EJ % ej.length) + 1} de ${ej.length}</span><button class="lnk" id="ejOtro">otro ejemplo</button>` : ''}<button class="lnk" id="ejOtra">ver otra vez</button></div>
|
||
<div class="ejcols"><div><div class="ejtit">Ahora</div><div class="ejarbol" id="ejAntes">${ramaHtml(ramas.antes, ramas.movil, 'antes')}</div></div><div class="ejflecha">→</div><div><div class="ejtit">Después</div><div class="ejarbol" id="ejDespues">${ramaHtml(ramas.despues, ramas.movil, 'despues')}</div></div></div>
|
||
<div class="ejtexto" id="ejTexto"><b>${fraseDe(r)}</b>${r.marcado ? '' : ` <span class="tag ${r.confianza}">sin marcar: ${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>`}<div class="pq">Por qué: ${esc(motivoLlano(r.motivo))}${r.n ? ` · ${num(r.n)} archivo${r.n > 1 ? 's' : ''} · ${gb(r.bytes)}` : ''}</div></div>
|
||
${otros ? `<div class="ejpie mut">Y lo mismo con ${otros === 1 ? 'el otro cambio de este paso, que está' : 'los otros ' + otros + ' cambios de este paso, que están'} en la lista de abajo. Un solo clic los hace todos.</div>` : ''}</div>`;
|
||
const o = $('#ejOtro'); if (o) o.onclick = () => { PLAN_EJ = (PLAN_EJ + 1) % ej.length; pintarEjemplo(); pintarLista(); };
|
||
$('#ejOtra').onclick = animarEjemplo;
|
||
animarEjemplo();
|
||
}
|
||
function animarEjemplo(){
|
||
const caja = $('#ejCaja'); if (!caja) return;
|
||
const a = caja.querySelector('#ejAntes .mv'), b = caja.querySelector('#ejDespues .mv'), tx = $('#ejTexto');
|
||
caja.querySelectorAll('.vuelo').forEach(x => x.remove());
|
||
a.classList.remove('fantasma'); b.classList.remove('llega'); b.style.visibility = ''; tx.classList.remove('visto');
|
||
const fin = () => { caja.querySelectorAll('.vuelo').forEach(x => x.remove()); b.style.visibility = ''; b.classList.add('llega'); a.classList.add('fantasma'); tx.classList.add('visto'); };
|
||
// si no se ve (otra pestaña) o Windows pide «menos movimiento», se enseña el resultado y ya
|
||
if (!caja.offsetParent || matchMedia('(prefers-reduced-motion: reduce)').matches) { fin(); return; }
|
||
const rc = caja.getBoundingClientRect(), ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
|
||
const v = a.cloneNode(true); v.classList.add('vuelo');
|
||
Object.assign(v.style, {position: 'absolute', left: (ra.left - rc.left) + 'px', top: (ra.top - rc.top) + 'px', margin: '0'});
|
||
caja.appendChild(v);
|
||
b.style.visibility = 'hidden'; a.classList.add('fantasma');
|
||
requestAnimationFrame(() => requestAnimationFrame(() => { v.style.transition = 'transform .9s cubic-bezier(.4,0,.2,1)'; v.style.transform = `translate(${rb.left - ra.left}px, ${rb.top - ra.top}px)`; }));
|
||
v.addEventListener('transitionend', fin, {once: true});
|
||
setTimeout(() => { if (v.isConnected) fin(); }, 1500);
|
||
}
|
||
// ---- la lista: de dónde → a dónde, con la cuenta; el porqué solo si se pide
|
||
function deHtml(r){
|
||
if (r.archivos && r.origen_rel === '(toda la raíz)') return `<b>${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}</b><span class="en">por toda la carpeta</span>`;
|
||
if (r.archivos) return `<b>${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''}</b><span class="en">en ${esc(r.origen_rel)}</span>`;
|
||
return `<b>${esc(ultimo(r.origen_rel))}</b><span class="en">en ${esc(padreDe(r.origen_rel) || 'la raíz')}</span>`;
|
||
}
|
||
function aHtml(r){
|
||
if (r.accion === 'renombrar') return `pasa a llamarse <b>${esc(ultimo(r.destino_rel))}</b>`;
|
||
if (!r.archivos && ultimo(r.destino_rel) === ultimo(r.origen_rel)) return esc(padreDe(r.destino_rel) || 'la raíz');
|
||
return esc(r.destino_rel);
|
||
}
|
||
function filaHtml(r, ej){
|
||
return `<div class="fila ${r.confianza} ${r.marcado ? '' : 'sinmarcar'} ${ej && ej.id === r.id ? 'esej' : ''}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"><div class="de">${deHtml(r)}</div><span class="fl">→</span><div class="a">${aHtml(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="meta">${r.n ? `${num(r.n)} arch. · ${gb(r.bytes)}` : ''}</div><div class="acts">${r.confianza !== 'alta' ? `<span class="tag ${r.confianza}">${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>` : ''}<button class="ed" data-ej="${r.id}" title="Verlo arriba, como ejemplo">ejemplo</button>${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div><div class="pq">${esc(motivoLlano(r.motivo))}</div></div>`;
|
||
}
|
||
function pintarLista(){
|
||
const caja = $('#planLista'), p = PASOS_PLAN.find(x => x.k === PLAN_PASO);
|
||
if (!p) { caja.innerHTML = ''; return; }
|
||
const f = ($('#filtroPlan').value || '').toLowerCase(), pasa = r => !f || (r.origen_rel + ' ' + r.destino_rel + ' ' + r.motivo).toLowerCase().includes(f);
|
||
const peso = {alta: 0, media: 1, baja: 2}, orden = (a, b) => (peso[a.confianza] - peso[b.confianza]) || b.bytes - a.bytes;
|
||
const todas = filasPaso(PLAN_PASO).filter(pasa).sort(orden), marc = todas.filter(r => r.marcado), sin = todas.filter(r => !r.marcado), ej = ejemploActual();
|
||
const grupos = rs => { const g = {}; rs.forEach(r => (g[r.cubeta] = g[r.cubeta] || []).push(r)); return Object.keys(g).map(c => `<div class="grp">${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}</div>` + g[c].map(r => filaHtml(r, ej)).join('')).join(''); };
|
||
const cos = sin.filter(r => r.cosmetico), dud = sin.filter(r => !r.cosmetico);
|
||
const explica = [cos.length ? `${cos.length === 1 ? 'uno solo cambia' : cos.length + ' solo cambian'} el nombre a la forma «NN- Título» (como «01 - Celular» → «01- Celular»): da igual hacerlo o no` : '', dud.length ? `${dud.length} ${dud.length === 1 ? 'es dudoso' : 'son dudosos'}: la app no está segura y no ${dud.length === 1 ? 'lo' : 'los'} mueve sin que lo marques tú` : ''].filter(Boolean).join('; ');
|
||
caja.innerHTML = `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`;
|
||
$('#chkPorQue').onchange = e => { PLAN_POR_QUE = e.target.checked; caja.querySelector('.lista').classList.toggle('porque', PLAN_POR_QUE); };
|
||
const bo = $('#btnVerOpc'); if (bo) bo.onclick = () => { PLAN_VER_OPC = !PLAN_VER_OPC; pintarLista(); };
|
||
const bc = $('#btnMarcarCosm'); if (bc) bc.onclick = () => { cos.forEach(r => r.marcado = true); pintarPlan(); };
|
||
// marcar o desmarcar una fila cambia las cifras, no la lista: así el foco se queda donde está
|
||
$$('#planLista .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; el.classList.toggle('sinmarcar', !r.marcado); pintarCabPlan(); estadoPlan(); }; });
|
||
$$('#planLista [data-ej]').forEach(b => b.onclick = () => { const i = ejemplosDe(PLAN_PASO).findIndex(x => x.id === +b.dataset.ej); if (i >= 0) { PLAN_EJ = i; pintarEjemplo(); pintarLista(); $('#ejCaja').scrollIntoView({behavior: 'smooth', block: 'nearest'}); } });
|
||
$$('#planLista [data-ed]').forEach(b => b.onclick = () => { const r = PLAN.filas.find(x => x.id === +b.dataset.ed); modal('Cambiar destino', `<p>${fraseDe(r)}</p><p class="mut">Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.</p><input type="text" id="mDest" style="width:100%" value="${esc(r.destino_rel)}">`, seguro(async () => { const v = $('#mDest').value.trim(); if (!v) return; r.destino_rel = v; r.editado = true; r.marcado = true; if (!r.archivos) await api('/api/regla', {cliente: PLAN.cliente, origen: r.origen_rel, destino: v, cubeta: r.cubeta}); pintarPlan(); })); });
|
||
}
|
||
$('#filtroPlan').oninput = pintarLista;
|
||
// ---- aplicar: la barra mientras se mueve, y al acabar qué se hizo y qué queda
|
||
const tiempoDe = s => s < 60 ? `${Math.max(1, Math.round(s))} s` : `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`;
|
||
function pintarAvance(s){
|
||
const caja = $('#planAvance');
|
||
if (s.activo) {
|
||
const u = s.unidades_total ? Math.min(100, Math.round(s.unidades * 100 / s.unidades_total)) : 0;
|
||
caja.innerHTML = `<div class="avance"><div class="l1"><span class="spin"></span><b>Moviendo…</b><span>${u} % · ${s.hechas || 0} de ${s.total} cambio${s.total > 1 ? 's' : ''}</span><span class="sep"></span><span class="mut">${num(s.movidos || 0)} movimiento${s.movidos === 1 ? '' : 's'}${s.fallos ? ` · <span class="bad">${s.fallos} no se pudieron</span>` : ''}</span></div><div class="bar grande"><i style="width:${u}%"></i></div><div class="l3 mut">${s.actual ? `ahora: «${esc(s.actual)}»${s.archivo ? ' · ' + esc(s.archivo) : ''}` : 'empezando…'}</div></div>`;
|
||
return;
|
||
}
|
||
const r = s.res, falt = r.errores.length;
|
||
caja.innerHTML = `<div class="avance listo ${falt ? 'conerr' : ''}"><div class="l1"><span class="ico">${falt ? '!' : '✓'}</span><b>${falt ? 'Hecho, con tropiezos.' : 'Listo.'}</b><span>${r.filas} de ${s.pedidas} cambio${s.pedidas > 1 ? 's' : ''} · ${num(r.movimientos)} movimiento${r.movimientos === 1 ? '' : 's'} · ${gb(s.bytes)} · ${tiempoDe(s.seg)}</span><span class="sep"></span>${r.lote ? `<button class="lnk" id="avDeshacer">Deshacer esto</button>` : ''}</div><div class="bar grande"><i style="width:100%"></i></div>${falt ? `<div class="l3 bad">${falt} no se pudieron mover. <button class="lnk" id="avErr" style="padding:0 4px">ver cuáles</button></div>` : ''}<div class="l3 mut" id="avQuedan"><span class="spin"></span> mirando qué queda…</div></div>`;
|
||
if (r.lote) $('#avDeshacer').onclick = () => deshacer(r.lote);
|
||
if (falt) $('#avErr').onclick = () => modal('No se pudieron mover', `<pre>${esc(r.errores.join('\n'))}</pre>`, null);
|
||
}
|
||
function pintarQueda(){
|
||
const el = $('#avQuedan'); if (!el || !PLAN) return;
|
||
const sin = PLAN.filas.filter(r => !r.marcado), marc = PLAN.filas.filter(r => r.marcado);
|
||
el.innerHTML = marc.length ? `Todavía hay ${marc.length} cambio${marc.length > 1 ? 's' : ''} marcado${marc.length > 1 ? 's' : ''} por aplicar.` : sin.length ? `Queda sin hacer: ${sin.length} cambio${sin.length > 1 ? 's' : ''} que la app no hace sola (${resumenOpc(sin)}). Están abajo, por si los quieres.` : 'No queda nada pendiente: la carpeta está en orden.';
|
||
}
|
||
function aplicarFilas(m, btn, titulo){
|
||
if (!m.length) return;
|
||
const bytes = m.reduce((a, r) => a + r.bytes, 0);
|
||
modal(titulo, `<p>Se hace${m.length > 1 ? 'n' : ''} <b>${m.length}</b> cambio${m.length > 1 ? 's' : ''} (${gb(bytes)}) dentro de <span class="path">${esc(PLAN.raiz)}</span>, seguido${m.length > 1 ? 's' : ''} y sin preguntar más.</p><p><b>Nada se borra</b> y se deshace desde Historial. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b>, para que no bloqueen archivos.</p>`, () => accion(btn, async () => {
|
||
const destinos = {}; m.forEach(r => { if (r.editado) destinos[r.id] = r.destino_rel; });
|
||
const raiz = PLAN.raiz, t0 = Date.now();
|
||
pintarAvance({activo: true, total: m.length, hechas: 0, unidades: 0, unidades_total: 0});
|
||
$('#planAvance').scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||
// mientras el servidor mueve, se le pregunta por dónde va (archivo a archivo); lo de una tanda anterior no vale
|
||
const reloj = setInterval(async () => { try { const a = await api('/api/aplicando'); if (a.inicio && a.inicio * 1000 >= t0 - 2000 && !a.fin) pintarAvance({activo: true, ...a, total: m.length}); } catch (e) {} }, 400);
|
||
let res;
|
||
try { res = await api('/api/aplicar', {raiz, ids: m.map(r => r.id), destinos}); }
|
||
catch (e) {
|
||
clearInterval(reloj); $('#planAvance').innerHTML = '';
|
||
// la carpeta cambió desde que se calculó (se apartó stock, se deshizo algo…): se calcula otra vez y ya
|
||
if (e.datos && e.datos.recalcular) { aviso('La carpeta cambió desde que se calculó el plan. Se vuelve a calcular; revísalo y aplica.', 'bad'); PLAN = null; await calcularPlan(); return; }
|
||
throw e;
|
||
}
|
||
clearInterval(reloj);
|
||
PLAN = null; MAPA = null;
|
||
pintarAvance({activo: false, res, seg: (Date.now() - t0) / 1000, pedidas: m.length, bytes});
|
||
aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(res.lote)});
|
||
await cargarEstado(); await calcularPlan(); pintarQueda(); pintarAcciones().catch(() => {});
|
||
}));
|
||
$('#modalSi').textContent = 'Aplicar';
|
||
}
|
||
function aplicarPaso(k, btn){ const p = PASOS_PLAN.find(x => x.k === k); aplicarFilas(PLAN.filas.filter(r => pasoDe(r) === k && r.marcado), btn, 'Paso: ' + p.t); }
|
||
$('#btnAplicarSeguros').onclick = () => aplicarFilas(PLAN.filas.filter(r => r.marcado), $('#btnAplicarSeguros'), 'Aplicar todo lo marcado');
|
||
$('#btnArbol').onclick = () => { const arbol = {}; PLAN.filas.filter(r => r.marcado).forEach(r => { const partes = r.destino_rel.split(/[\\/]/); let n = arbol; partes.forEach(p => { n[p] = n[p] || {}; n = n[p]; }); }); const lineas = []; const dibuja = (n, pref) => { const ks = Object.keys(n).sort(); ks.forEach((k, i) => { const ult = i === ks.length - 1; lineas.push(pref + (ult ? '└─ ' : '├─ ') + k); dibuja(n[k], pref + (ult ? ' ' : '│ ')); }); }; dibuja(arbol, ''); modal('Cómo quedaría (solo lo marcado)', `<div class="tree">${esc(PLAN.raiz)}\n${esc(lineas.join('\n'))}</div>`, null); };
|
||
|
||
// ---------------- revisar: stock viejo ----------------
|
||
$$('#s-stock button[data-m]').forEach(b => b.onclick = () => { $('#stockMeses').value = b.dataset.m; if (STOCK.length) { STOCK.forEach(g => g.sel = g.meses >= +b.dataset.m); pintarStock(); } });
|
||
$('#stockMeses').onchange = () => { if (STOCK.length) { STOCK.forEach(g => g.sel = g.meses >= (+$('#stockMeses').value || 0)); pintarStock(); } };
|
||
const TRAMOS = [[12, 'Más de 12 meses'], [6, 'Entre 6 y 12 meses'], [3, 'Entre 3 y 6 meses'], [1, 'Entre 1 y 3 meses'], [0, 'Menos de 1 mes']];
|
||
$('#btnStock').onclick = () => accion($('#btnStock'), async () => {
|
||
const tipos = [$('#stockVideo').checked && 'Video', $('#stockAudio').checked && 'Audio', $('#stockImg').checked && 'Imagenes'].filter(Boolean).join(',');
|
||
const d = await api(`/api/stock?raiz=${encodeURIComponent(raizActual())}&meses=0&tipos=${tipos}`);
|
||
const hoy = new Date(); const ahoraM = hoy.getFullYear() * 12 + hoy.getMonth();
|
||
const min = +$('#stockMeses').value || 0;
|
||
STOCK = d.grupos.map(g => { const m = /^(\d{4})-(\d{2})/.exec(g.mes || ''); const meses = m ? ahoraM - ((+m[1]) * 12 + (+m[2]) - 1) : 0; return {...g, meses, sel: meses >= min, raiz: raizActual()}; });
|
||
$('#stockResumen').textContent = `${d.n} archivos de stock · ${gb(d.bytes)} en total`;
|
||
$('#stockCaja').style.display = ''; pintarStock();
|
||
});
|
||
function pintarStock(){
|
||
const tramoDe = g => TRAMOS.find(t => g.meses >= t[0]) || TRAMOS[TRAMOS.length - 1];
|
||
let html = '';
|
||
TRAMOS.forEach(t => {
|
||
const gs = STOCK.map((g, i) => ({g, i})).filter(x => tramoDe(x.g) === t);
|
||
if (!gs.length) return;
|
||
const todos = gs.every(x => x.g.sel);
|
||
html += `<tr class="stk-grp"><td><input type="checkbox" data-t="${t[0]}" ${todos ? 'checked' : ''} aria-label="marcar tramo"></td><td colspan="2">${t[1]}</td><td class="num">${num(gs.reduce((a, x) => a + x.g.n, 0))}</td><td class="num">${gb(gs.reduce((a, x) => a + x.g.bytes, 0))}</td><td class="mut" style="font-weight:500">${gs.length} carpeta${gs.length > 1 ? 's' : ''}</td></tr>`;
|
||
html += gs.map(({g, i}) => `<tr><td><input type="checkbox" data-i="${i}" ${g.sel ? 'checked' : ''} aria-label="marcar"></td><td class="path">${esc(g.carpeta)}</td><td>${esc(g.mes)}</td><td class="num">${g.n}</td><td class="num">${gb(g.bytes)}</td><td class="cell-small">${g.ejemplos.map(esc).join('<br>')}</td></tr>`).join('');
|
||
});
|
||
const caja = $('#stockCaja'), y = caja ? caja.scrollTop : 0;
|
||
$('#tablaStock tbody').innerHTML = html || '<tr><td colspan="6" class="mut">No hay stock de Envato en esta carpeta.</td></tr>';
|
||
if (caja) caja.scrollTop = y;
|
||
$$('#tablaStock input[data-i]').forEach(i => i.onchange = () => { STOCK[+i.dataset.i].sel = i.checked; pintarStock(); });
|
||
$$('#tablaStock input[data-t]').forEach(i => i.onchange = () => { const t = TRAMOS.find(x => x[0] === +i.dataset.t); STOCK.forEach(g => { if (tramoDe(g) === t) g.sel = i.checked; }); pintarStock(); });
|
||
resumenStock();
|
||
}
|
||
function resumenStock(){ const s = STOCK.filter(g => g.sel); bloquear('btnStockPapelera', STOCK.length ? (s.length ? '' : 'Marca alguna carpeta') : 'Busca primero'); $('#btnStockPapelera').textContent = s.length ? `Apartar ${s.length} carpeta${s.length > 1 ? 's' : ''} (${gb(s.reduce((a, g) => a + g.bytes, 0))})` : 'Apartar a _Para borrar/stock'; }
|
||
$('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g.raiz === raizActual()); const rutas = s.flatMap(g => g.rutas); if (!rutas.length) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad'); modal('Apartar stock de Envato', `<p><b>${rutas.length}</b> archivos (${gb(s.reduce((a,g)=>a+g.bytes,0))}) se mueven a <span class="path">_Para borrar/stock</span> dentro de la carpeta del cliente. Nada se borra; se deshace desde Historial.</p>`, () => accion($('#btnStockPapelera'), async () => { const r = await api('/api/stock/papelera', {raiz: raizActual(), rutas}); aviso(`${r.movimientos} archivos de stock apartados.`, 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); STOCK = []; pintarStock(); $('#stockCaja').style.display = 'none'; MAPA = null; await cargarEstado(); })); $('#modalSi').textContent = 'Apartar'; };
|
||
|
||
// ---------------- convertir ----------------
|
||
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses y más de ${+$('#optMin').value || 0} MB`; }
|
||
['optMeses','optMin'].forEach(id => $('#' + id).oninput = pintarCriterio);
|
||
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'carpeta', inverso: false}, CONV_EXC = '', CONV_PLEGADAS = new Set(), CONV_BUSCA = '', CONV_CRITERIO = '';
|
||
const NOMBRE_CAM = {braw: 'BRAW', h264: 'H.264', hevc: 'H.265', h265: 'H.265', prores: 'ProRes', mpeg4: 'MPEG-4', mpeg2video: 'MPEG-2', dnxhd: 'DNxHD', vp9: 'VP9', av1: 'AV1'};
|
||
const MES_LARGO = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
|
||
// los criterios de «marcar»: cada uno sabe decir cuántos clips coge
|
||
function criteriosDe(marcables){
|
||
const hoy = Date.now(), anio = 365 * 86400000, cods = {}, meses = {};
|
||
marcables.forEach(c => { const k = NOMBRE_CAM[(c.codec || '').toLowerCase()] || (c.codec || '').toUpperCase(); cods[k] = (cods[k] || 0) + 1; const m = (c.fecha || '').slice(0, 7); if (m) meses[m] = (meses[m] || 0) + 1; });
|
||
const cr = [['todos', 'todos los válidos', c => true], ['grandes', 'los de más de 5 GB', c => c.bytes > 5e9]];
|
||
Object.keys(cods).sort((a, b) => cods[b] - cods[a]).forEach(k => cr.push(['cam:' + k, 'solo ' + k, c => (NOMBRE_CAM[(c.codec || '').toLowerCase()] || (c.codec || '').toUpperCase()) === k]));
|
||
Object.keys(meses).sort().slice(-6).forEach(m => cr.push(['mes:' + m, `${MES_LARGO[+m.slice(5, 7) - 1] || m} ${m.slice(0, 4)}`, c => (c.fecha || '').slice(0, 7) === m]));
|
||
cr.push(['viejos', 'de más de un año', c => c.fecha && hoy - new Date(c.fecha).getTime() > anio]);
|
||
return cr.map(([k, t, p]) => ({k, t, p, n: marcables.filter(p).length})).filter(x => x.n);
|
||
}
|
||
const tiempoLargo = s => { s = Math.max(0, Math.round(s || 0)); if (s < 60) return `${s} s`; if (s < 3600) return `${Math.floor(s / 60)} min${s % 60 ? ' ' + (s % 60) + ' s' : ''}`; return `${Math.floor(s / 3600)} h${Math.round(s % 3600 / 60) ? ' ' + Math.round(s % 3600 / 60) + ' min' : ''}`; };
|
||
// por qué no se ofrece un clip, en grupos: 502 filas grises con un «no» no explican nada
|
||
function claveExc(m){
|
||
m = m || '';
|
||
if (m.startsWith('ya es una conversión')) return 'ya los convirtió el programa';
|
||
if (m.startsWith('ya viene comprimido')) return 'ya vienen comprimidos de cámara';
|
||
if (m.startsWith('ya se probó')) return 'ya se probaron con esta calidad';
|
||
if (m.startsWith('tiene menos de')) return m.replace('tiene menos de', 'de menos de');
|
||
if (m.startsWith('no se pudo leer')) return 'no se pudieron leer';
|
||
if (m.startsWith('es un exportado')) return 'exportados y masters';
|
||
if (m.startsWith('stock')) return 'stock de Envato';
|
||
if (m.startsWith('tiene canal alfa')) return 'con canal alfa';
|
||
return m.split(':')[0];
|
||
}
|
||
$('#btnBuscarClips').onclick = () => accion($('#btnBuscarClips'), async () => {
|
||
const cv = (CARPETA && CARPETA.convertir) || {};
|
||
if (cv.sin_analizar > 0 || (cv.analisis && cv.analisis.estado === 'error')) {
|
||
const r = await api('/api/optimizar/analizar', {raiz: raizActual(), min_mb: +$('#optMin').value || 0});
|
||
if (!r.lanzado) throw new ApiError('Ya se están leyendo los clips de esta carpeta');
|
||
BUSCANDO = true; $('#anaEstado').innerHTML = '<span class="spin"></span> leyendo los clips (códec, duración, resolución)…';
|
||
await cargarEstado();
|
||
} else await cargarCandidatos();
|
||
});
|
||
let MOTORES = null;
|
||
function pintarFormato(){ const f = (ESTADO.formatos || {})[$('#optFormato').value]; $('#fmtQue').textContent = f ? f.que : ''; }
|
||
$('#optFormato').onchange = () => { pintarFormato(); if (CAND.length) cargarCandidatos().catch(e => aviso(e.message, 'bad')); };
|
||
async function cargarMotores(){
|
||
try { MOTORES = await api('/api/optimizar/motores'); } catch (e) { $('#motores').textContent = 'no se pudo detectar GPU/CPU'; return; }
|
||
const g = MOTORES.gpu, c = MOTORES.cpu;
|
||
const gpuT = g.hevc || g.h264 ? `GPU ${esc(g.nombre || 'NVIDIA')}: ${[g.hevc && 'H.265', g.h264 && 'H.264'].filter(Boolean).join(' y ')} acelerados` : 'Sin GPU NVIDIA: todo por CPU (más lento)';
|
||
const cpuT = `CPU: ${[c.prores_ks && 'ProRes', c.libx265 && 'H.265', c.libx264 && 'H.264'].filter(Boolean).join(', ') || 'nada'}`;
|
||
const lb = MOTORES.braw || {};
|
||
const brawT = lb.ok ? 'BRAW: se leen sin abrir Resolve' : `<span class="bad" title="${esc(lb.motivo || '')}">BRAW: falta el decodificador de Blackmagic</span>`;
|
||
$('#motores').innerHTML = MOTORES.ffmpeg ? `${gpuT} · ${cpuT} · ${brawT}` : '<span class="bad">ffmpeg no encontrado: no se puede convertir</span>';
|
||
const ch = $('#optChroma'); const op422 = ch && ch.querySelector('option[value="422"]');
|
||
if (op422) { const hay = MOTORES.chroma422 !== false; op422.disabled = !hay; op422.textContent = '4:2:2 10 bits' + (hay ? (MOTORES.gpu.chroma422 ? ' (pesa más)' : ' (por CPU, más lento)') : ' (este equipo no puede)'); if (!hay && ch.value === '422') ch.value = '420'; }
|
||
$$('#optFormato option').forEach(o => { const m = MOTORES.formatos[o.value]; o.disabled = !m; o.textContent = (ESTADO.formatos[o.value] || {}).nombre + (m === 'gpu' ? ' · GPU' : m === 'cpu' ? ' · CPU' : ' · no disponible'); });
|
||
}
|
||
async function cargarCandidatos(){
|
||
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}`;
|
||
const d = await api('/api/optimizar/candidatos?' + q); CAND = d.candidatos.map(c => ({...c, sel: !c.excluido && !c.en_cola}));
|
||
$('#anaEstado').textContent = '';
|
||
if (!CAND.length) aviso('No se encontraron clips con esos criterios. Prueba a cambiar los meses o el tamaño mínimo.', '');
|
||
await cargarCola();
|
||
}
|
||
function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND.filter(c => !c.excluido && !c.en_cola); bloquear('btnEncolar', validos.length ? (s.length ? '' : 'x') : 'x'); $('#btnEncolar').textContent = s.length ? `Convertir ${s.length} marcado${s.length > 1 ? 's' : ''}` : 'Convertir'; }
|
||
$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola o su salida ya existe (el mismo clip en MP4 y MOV, o una conversión anterior).`, ''); 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', descartado:'no compensa'};
|
||
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, descartado:4, reemplazado:5, cancelado:6};
|
||
ts.sort((x, y) => (orden[x.estado] ?? 9) - (orden[y.estado] ?? 9) || y.id - x.id);
|
||
COLA_TS = ts;
|
||
const n = k => ts.filter(t => t.estado === k).length;
|
||
const pend = n('pendiente'), listos = n('listo'), verif = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores')).length, err = n('error') + n('cancelado');
|
||
const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}${n('descartado') ? ` · ${n('descartado')} que no compensan` : ''}` : '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','descartado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none';
|
||
$('#btnQuitarEspera').style.display = (!d.cola.activa && pend) ? '' : 'none';
|
||
bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar';
|
||
const validos = CAND.filter(c => !c.excluido), ahora = validos.reduce((s,c)=>s+c.bytes,0), despues = validos.reduce((s,c)=>s+c.estimado,0);
|
||
const hechosB = ts.filter(t => t.bytes_out).reduce((s,t) => s + (t.bytes_in - t.bytes_out), 0);
|
||
const reemp = n('reemplazado');
|
||
// antes y después, en una barra: lo que pesa hoy, lo que pesará, y lo que se libera
|
||
const pConv = Math.max(2, Math.min(98, Math.round(despues / (ahora || 1) * 100)));
|
||
$('#optCards').innerHTML = (CAND.length || ts.length) ? `<div class="antes"><div class="l1"><span class="cabsec" style="margin:0">Antes y después</span><span class="sep"></span><span class="mut">${validos.length} clip${validos.length !== 1 ? 's' : ''} convertible${validos.length !== 1 ? 's' : ''}</span></div>${ahora ? `<div class="l2"><b>${gb(ahora)}</b><div class="pista"><i class="conv" style="width:${pConv}%"></i><i class="libre" style="width:${100 - pConv}%"></i></div><b class="acc">${gb(despues)}</b></div><div class="l3 mut"><span><i class="sw conv"></i>lo que pesarán los convertidos</span><span><i class="sw libre"></i>lo que se libera: ${gb(Math.max(0, ahora - despues))}</span><span class="sep"></span><span>${hechosB > 0 ? `<b class="ok">${gb(hechosB)} menos</b> en ${verif + reemp} ya convertidos${reemp ? '' : ' (se libera al reemplazar)'}` : `${verif} verificados`}</span></div>` : `<div class="l3 mut"><span>${hechosB > 0 ? `<b class="ok">${gb(hechosB)} menos</b> en ${verif + reemp} clips convertidos${reemp ? '' : ' (se libera al reemplazar)'}` : 'nada convertible ahora mismo'}</span></div>`}</div>` : '';
|
||
// Una sola lista: los candidatos con el estado VIVO de su trabajo (la ficha de «Buscar» se quedaba con
|
||
// «corriendo» en un clip que ya había acabado), más los trabajos de clips que ya no son candidatos. Va por
|
||
// carpetas (la ruta una sola vez) o plana si se ordena por otra cosa, y el que se convierte lleva su barra.
|
||
const fmt = $('#optFormato').value || 'h265_10bit';
|
||
const porRuta = {};
|
||
ts.forEach(t => { const k = (t.ruta || '').toLowerCase(); if (!porRuta[k] || (orden[t.estado] ?? 9) < (orden[porRuta[k].estado] ?? 9)) porRuta[k] = t; });
|
||
const bloquea = t => t && ['pendiente', 'corriendo', 'listo', 'verificado'].includes(t.estado) && (t.formato || 'h265_10bit') === fmt;
|
||
const filas = CAND.map((c, i) => { const t = porRuta[(c.ruta || '').toLowerCase()] || null; c.en_cola = bloquea(t) ? t.estado : ''; if (c.en_cola) c.sel = false; return {c, i, t}; });
|
||
const vistas = new Set(CAND.map(c => (c.ruta || '').toLowerCase()));
|
||
ts.forEach(t => { if (!vistas.has((t.ruta || '').toLowerCase())) filas.push({c: null, i: -1, t}); });
|
||
resumenCand();
|
||
const relDe = f => f.c ? f.c.rel : (f.t.raiz && (f.t.ruta || '').toLowerCase().startsWith(f.t.raiz.toLowerCase()) ? f.t.ruta.slice(f.t.raiz.length).replace(/^[\\/]/, '') : (f.t.ruta || ''));
|
||
const nombre = f => ultimo(relDe(f));
|
||
const carpeta = f => padreDe(relDe(f));
|
||
const bytes = f => f.c ? f.c.bytes : (f.t.bytes_in || 0);
|
||
const sale = f => f.t && f.t.bytes_out ? f.t.bytes_out : f.c ? f.c.estimado : 0;
|
||
const ahorro = f => bytes(f) && sale(f) ? 1 - sale(f) / bytes(f) : -9;
|
||
const rango = f => f.t ? ({corriendo: 0, pendiente: 1, listo: 3, verificado: 3, error: 4, descartado: 5, reemplazado: 6, cancelado: 6}[f.t.estado] ?? 7) : f.c.excluido ? 8 : 2;
|
||
const grupo = f => f.t && ['pendiente', 'corriendo'].includes(f.t.estado) ? 'cola' : f.t && ['listo', 'verificado', 'reemplazado'].includes(f.t.estado) ? 'hechos' : f.t && ['error', 'cancelado', 'descartado'].includes(f.t.estado) ? 'fallos' : f.c && f.c.excluido ? 'no' : 'libres';
|
||
const CLAVES = {carpeta: (a, b) => carpeta(a).localeCompare(carpeta(b), 'es') || rango(a) - rango(b) || bytes(b) - bytes(a), estado: (a, b) => rango(a) - rango(b) || bytes(b) - bytes(a), nombre: (a, b) => nombre(a).localeCompare(nombre(b), 'es'), fecha: (a, b) => ((b.c || {}).fecha || '').localeCompare((a.c || {}).fecha || '') || bytes(b) - bytes(a), tamano: (a, b) => bytes(b) - bytes(a), ahorro: (a, b) => ahorro(b) - ahorro(a)};
|
||
const busca = CONV_BUSCA.trim().toLowerCase();
|
||
const lista = filas.filter(f => (CONV_VER === 'todos' || grupo(f) === CONV_VER) && (!CONV_EXC || (f.c && claveExc(f.c.excluido) === CONV_EXC)) && (!busca || relDe(f).toLowerCase().includes(busca))).sort(CLAVES[CONV_ORDEN.campo] || CLAVES.carpeta);
|
||
if (CONV_ORDEN.inverso) lista.reverse();
|
||
const cuenta = {}; filas.forEach(f => cuenta[grupo(f)] = (cuenta[grupo(f)] || 0) + 1);
|
||
const E = ESTADO && ESTADO.cola, corr = ts.find(t => t.estado === 'corriendo'), eta = d.cola.eta;
|
||
const fmtDe = x => esc(((ESTADO.formatos || {})[x || 'h265_10bit'] || {}).nombre || x || '').split(' · ')[0];
|
||
const primeroPend = (ts.filter(t => t.estado === 'pendiente').sort((a, b) => (b.orden || 0) - (a.orden || 0) || a.id - b.id)[0] || {}).id;
|
||
const filaConv = (f, conCarpeta) => {
|
||
const {c, t, i} = f;
|
||
const motorT = t ? (t.motor === 'resolve' ? 'Resolve' : (t.motor === 'braw' ? 'BRAW · ' : '') + (MOTORES && MOTORES.formatos[t.formato || 'h265_10bit'] === 'gpu' ? 'GPU' : 'CPU')) + ' · ' + fmtDe(t.formato) : c.motor === 'braw' ? 'lector de BRAW' : c.motor === 'resolve' ? 'Resolve' : 'ffmpeg';
|
||
const corre = t && t.estado === 'corriendo', pct = corre ? Math.max(0, Math.min(100, Math.round(t.pct || 0))) : 0;
|
||
const est = corre ? `<span class="est prog ${pct >= 50 ? 'media' : ''}" style="--p:${pct}%" title="convirtiendo: ${pct} %">${pct} %</span>` : t ? `<span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}</span>` : `<span class="est candidato">${c.excluido ? 'no' : 'candidato'}</span>`;
|
||
let sub = c ? `${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${motorT}` : motorT;
|
||
if (corre) { const a = eta && eta.actual && eta.actual.id === t.id ? eta.actual : null; sub = `<span class="acc">convirtiendo${a && a.dur ? ` · ${tiempoLargo(a.dur - a.resto)} de ${tiempoLargo(a.dur)} · quedan ${tiempoLargo(a.resto_seg)}` : ''}${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''}</span> · ${motorT}`; }
|
||
else if (t && t.estado === 'error') sub = `<span class="bad" title="${esc(t.log || '')}">${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}</span>`;
|
||
else if (t && t.estado === 'descartado') sub = `<span class="mut">${esc(t.log || '')}</span>`;
|
||
else if (t && t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió')) sub = `<span class="mut">${esc(t.log)}</span>`;
|
||
else if (t && ['listo', 'verificado', 'reemplazado'].includes(t.estado)) sub = `${t.nota ? `nota de calidad <b>${t.nota} / 100</b> · ` : ''}${esc((t.salida || '').split(/[\\/]/).pop())} · ${motorT}`;
|
||
else if (c && c.excluido) sub += ' · ' + esc(c.excluido);
|
||
if (conCarpeta) sub = `<span class="carp">${esc(carpeta(f) || '(raíz)')}</span> · ` + sub;
|
||
const errCls = t && (t.estado === 'error' || t.estado === 'descartado') ? ' err' : '';
|
||
const bIn = bytes(f), bOut = t && t.bytes_out ? t.bytes_out : 0, bEst = sale(f);
|
||
const tam = bOut ? `${gb(bIn)} → ${gb(bOut)}` : c ? `${gb(c.bytes)} → ≈ ${gb(c.estimado)}` : gb(bIn);
|
||
const ah = bIn && bEst ? (bEst <= bIn ? '−' + Math.round((1 - bEst / bIn) * 100) : '+' + Math.round((bEst / bIn - 1) * 100)) + '%' : '';
|
||
const acts = t && ['listo', 'verificado', 'reemplazado'].includes(t.estado) ? `<button data-comparar="${t.id}" title="Ver el mismo cuadro en el original y en el convertido">comparar</button>` : t && t.estado === 'pendiente' && t.id !== primeroPend ? `<button data-primero="${t.id}" title="Pasa delante de los que esperan">primero este</button>` : '';
|
||
const titulo = c ? `${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` : esc(t.ruta || '');
|
||
return `<div class="cfila ${c && c.excluido ? 'exc' : ''} ${corre ? 'activa' : ''}">${c ? `<input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar">` : '<span></span>'}${est}<span class="nom" title="${titulo}"><span class="clip">${esc(nombre(f))}</span><span class="sub2${errCls}">${sub}</span></span><span class="tam">${tam}</span><span class="ah${ah.startsWith('+') ? ' bad' : ''}">${ah}</span><span class="acts">${acts}</span></div>`;
|
||
};
|
||
// ---- el momento: un solo botón grande, el que toca ahora
|
||
const sel = CAND.filter(c => c.sel), libres = CAND.filter(c => !c.excluido && !c.en_cola);
|
||
const vers = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores'));
|
||
const notas = vers.filter(t => t.nota), notaMedia = notas.length ? (notas.reduce((s, t) => s + t.nota, 0) / notas.length).toFixed(0) : '';
|
||
let mo = '';
|
||
if (d.cola.activa && d.cola.esperando) {
|
||
mo = `<div class="momento"><div class="l1"><span class="spin"></span><b>La cola está en pausa</b><span class="mut">${esc(d.cola.esperando)} · ${pend} en espera · se cambia en «Cuándo trabaja la cola»</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div></div>`;
|
||
} else if (d.cola.activa) {
|
||
const total = E && E.total ? E.total : (pend + (corr ? 1 : 0)), hechosT = E ? (E.hechos || 0) : 0;
|
||
const pctTot = total ? Math.round((hechosT + (corr ? (corr.pct || 0) / 100 : 0)) / total * 100) : 0;
|
||
mo = `<div class="momento corre"><div class="l1"><span class="spin"></span><b>Convirtiendo ${Math.min(hechosT + 1, total)} de ${total}</b><span class="mut">${eta && eta.seg ? `quedan unas <b>${tiempoLargo(eta.seg)}</b> · termina hacia las <b>${hora(eta.termina)}</b>${eta.medido ? ` · va a ${eta.vel.toFixed(1)}× tiempo real` : ' · estimado, hasta que acabe el primero'}` : 'calculando cuánto falta…'}</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div><div class="bar grande"><i style="width:${pctTot}%"></i></div><div class="l3 mut">${corr ? `ahora: <b>${esc((corr.ruta || '').split(/[\\/]/).pop())}</b> · ${Math.round(corr.pct || 0)} %${eta && eta.actual ? ` · quedan ${tiempoLargo(eta.actual.resto_seg)} de este clip` : ''} · ${eta ? eta.en_espera : pend} en espera` : 'empezando…'}</div></div>`;
|
||
} else if (pend) {
|
||
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">lo que ya iba se retoma desde cero</span><span class="sep"></span><button class="p grande" id="moReanudar">Reanudar la cola</button></div></div>`;
|
||
} else if (vers.length) {
|
||
const bIn = vers.reduce((s, t) => s + (t.bytes_in || 0), 0), bOut = vers.reduce((s, t) => s + (t.bytes_out || 0), 0);
|
||
mo = `<div class="momento listo"><div class="l1"><span class="ico">✓</span><span><b>${vers.length} clip${vers.length > 1 ? 's' : ''} convertido${vers.length > 1 ? 's' : ''} y verificado${vers.length > 1 ? 's' : ''}</b> · ${gb(bIn)} → ${gb(bOut)}${notaMedia ? ` · nota media ${notaMedia} / 100` : ''}</span><span class="sep"></span><button class="lnk" id="moComparar">comparar unos cuantos</button><button class="ok grande" id="moReemplazar">Reemplazar ${vers.length > 1 ? 'los ' + vers.length + ' originales' : 'el original'} · libera ${gb(Math.max(0, bIn - bOut))}</button></div><div class="l3 mut">Los originales van a _Para borrar/originales, no se borran, y se deshace desde Historial. Si quieres verlo antes, «comparar» enseña el mismo cuadro en los dos y mide la calidad.</div></div>`;
|
||
} else if (sel.length) {
|
||
const segs = sel.reduce((s, c) => s + (c.dur || 0) / (c.motor === 'braw' ? 1 : 2.5), 0);
|
||
mo = `<div class="momento"><div class="l1"><button class="p grande" id="moConvertir">Convertir ${sel.length} clip${sel.length > 1 ? 's' : ''} · ${gb(sel.reduce((s, c) => s + c.bytes, 0))} → ≈ ${gb(sel.reduce((s, c) => s + c.estimado, 0))}</button><span class="mut">unas ${tiempoLargo(segs)} en la tarjeta · el original no se toca</span><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="moAuto" ${(ESTADO.config.optimizar || {}).auto_reemplazar ? 'checked' : ''}> y reemplazar solo cuando estén verificados</label></div></div>`;
|
||
} else if (CAND.length) {
|
||
mo = `<div class="momento"><div class="l1"><b>${libres.length ? `${libres.length} clip${libres.length > 1 ? 's' : ''} por convertir, ninguno marcado` : 'Nada por convertir ahora mismo'}</b><span class="mut">${libres.length ? 'marca los que quieras en la lista, o todos los válidos' : 'lo que había ya está convertido o no compensa; con el tiempo saldrá material nuevo'}</span></div></div>`;
|
||
} else {
|
||
mo = `<div class="momento"><div class="l1"><button class="p grande" id="moBuscar">Buscar clips que se puedan aligerar</button><span class="mut">lee los clips de más de ${+$('#optMeses').value || 0} meses y ${+$('#optMin').value || 0} MB, y calcula cuánto pesarían. No convierte nada todavía.</span></div></div>`;
|
||
}
|
||
$('#momento').innerHTML = mo;
|
||
// la última tanda, con su resumen, hasta que se oculte o empiece otra
|
||
const ul = d.cola.ultima, vista = (() => { try { return localStorage.getItem('organilab.tanda.vista'); } catch (e) { return ''; } })();
|
||
if (!d.cola.activa && ul && ul.lote && ul.clips && ul.lote !== vista && (!raizActual() || !ul.raices || !ul.raices.length || ul.raices.some(x => x.toLowerCase() === raizActual().toLowerCase()))) {
|
||
const carps = (ul.carpetas || []).slice(0, 3), maxB = Math.max(1, ...carps.map(c => c.bytes_in));
|
||
$('#tanda').innerHTML = `<div class="tanda"><div class="l1"><span class="ico" style="width:22px;height:22px;border-radius:50%;background:var(--ok2);color:var(--ok);display:inline-flex;align-items:center;justify-content:center;font-weight:700">✓</span><b>Tanda terminada${ul.cliente ? ' · ' + esc(ul.cliente) : ''}</b><span class="mut">${ul.inicio ? `empezó el ${new Date(ul.inicio * 1000).toLocaleDateString('es-CO', {day: 'numeric', month: 'long'})} a las ${hora(ul.inicio)} · ` : ''}acabó a las ${hora(ul.ts || (ul.inicio + ul.seg))} · ${tiempoLargo(ul.seg)}</span><span class="sep"></span><a class="lnk" href="/api/optimizar/tanda.csv?lote=${encodeURIComponent(ul.lote)}" download style="padding:5px 6px;font-weight:600">Exportar CSV</a>${ul.raiz ? '<button id="taAbrir">Abrir la carpeta</button>' : ''}<button class="lnk" id="taOcultar">ocultar</button></div>
|
||
<div class="cifras"><div><b>${ul.clips}</b><small>clip${ul.clips !== 1 ? 's' : ''} convertido${ul.clips !== 1 ? 's' : ''}${ul.verificados || ul.reemplazados ? ' y verificado' + (ul.clips !== 1 ? 's' : '') : ''}${ul.pedidos > ul.clips ? ` de ${ul.pedidos}` : ''}</small></div><div><b>${gb(ul.bytes_in)} → ${gb(ul.bytes_out)}</b><small>${ul.bytes_out ? Math.round(ul.bytes_in / ul.bytes_out) + ' veces menos' : ''}</small></div><div><b class="ok">${gb(Math.max(0, ul.bytes_in - ul.bytes_out))}</b><small>${ul.reemplazados === ul.clips ? 'liberados' : 'se liberan al reemplazar'}</small></div><div><b>${ul.nota ? ul.nota + ' / 100' : '—'}</b><small>${ul.nota ? `nota media · la peor, ${ul.nota_min}` : 'sin medir: «comparar» mide'}</small></div><div><b class="${ul.fallos || ul.descartados ? 'warn' : ''}">${(ul.fallos || 0) + (ul.descartados || 0)}</b><small>${ul.fallos ? ul.fallos + ' fallaron' : ''}${ul.fallos && ul.descartados ? ' · ' : ''}${ul.descartados ? ul.descartados + ' no compensaban' : ''}${!ul.fallos && !ul.descartados ? 'sin fallos' : ''}</small></div></div>
|
||
${carps.length ? `<div style="display:flex;flex-direction:column;gap:4px"><span class="cabsec" style="margin:0">Por carpeta</span>${carps.map(c => `<div class="carp"><span class="n" title="${esc(c.carpeta)}">${esc((c.carpeta || '').split(/[\\/]/).filter(Boolean).join(' › ') || '(raíz)')}</span><div class="b"><i style="width:${Math.round(c.bytes_in / maxB * 100)}%"></i></div><span class="c">${c.clips} clip${c.clips !== 1 ? 's' : ''} · ${gb(c.bytes_in)} → ${gb(c.bytes_out)}</span></div>`).join('')}${(ul.carpetas || []).length > 3 ? `<span class="mut" style="font-size:12px">y ${ul.carpetas.length - 3} carpeta${ul.carpetas.length - 3 > 1 ? 's' : ''} más: están en el CSV</span>` : ''}</div>` : ''}</div>`;
|
||
const ta = $('#taAbrir'); if (ta) ta.onclick = () => api('/api/abrir', {ruta: ul.raiz}).catch(e => aviso(e.message, 'bad'));
|
||
$('#taOcultar').onclick = () => { try { localStorage.setItem('organilab.tanda.vista', ul.lote); } catch (e) {} $('#tanda').innerHTML = ''; };
|
||
} else $('#tanda').innerHTML = '';
|
||
const liga = (id, a) => { const b = $('#' + id); if (b) b.onclick = a; };
|
||
liga('moDetener', () => $('#btnColaToggle').click()); liga('moReanudar', () => $('#btnColaToggle').click());
|
||
liga('moReemplazar', () => $('#btnReemplazar').click()); liga('moConvertir', () => $('#btnEncolar').click()); liga('moBuscar', () => $('#btnBuscarClips').click());
|
||
liga('moComparar', () => { const v = vers.slice().sort((a, b) => (b.bytes_in || 0) - (a.bytes_in || 0))[0]; if (v) comparar(v.id); });
|
||
const ma = $('#moAuto'); if (ma) ma.onchange = seguro(async () => { await api('/api/config', {optimizar: {auto_reemplazar: ma.checked}}); ESTADO.config.optimizar.auto_reemplazar = ma.checked; });
|
||
// ---- la lista
|
||
let html = '';
|
||
if (filas.length) {
|
||
const marcables = CAND.filter(c => !c.excluido && !c.en_cola);
|
||
const VER = [['todos', 'todos', filas.length], ['libres', 'por convertir', cuenta.libres], ['cola', 'en cola', cuenta.cola], ['hechos', 'terminados', cuenta.hechos], ['fallos', 'con fallo', cuenta.fallos], ['no', 'excluidos', cuenta.no]];
|
||
const ORD = [['carpeta', 'carpeta'], ['estado', 'estado'], ['nombre', 'nombre'], ['fecha', 'fecha'], ['tamano', 'tamaño'], ['ahorro', 'ahorro']];
|
||
if (marcables.length) {
|
||
// marcar con criterio: por tamaño, por cámara, por mes, por antigüedad; y un buscador
|
||
const cr = criteriosDe(marcables), nSel = marcables.filter(c => c.sel).length;
|
||
html += `<div class="marcar"><span>marcar</span>${cr.map(x => `<button data-criterio="${esc(x.k)}" class="${CONV_CRITERIO === x.k ? 'on' : ''}">${esc(x.t)} · ${x.n}</button>`).join('')}<button class="lnk" data-criterio="ninguno" style="border:0;background:none">ninguno</button><input type="search" id="convBusca" placeholder="buscar por nombre o carpeta…" value="${esc(CONV_BUSCA)}" aria-label="buscar clip"></div>`;
|
||
if (nSel) html += `<div class="mut" style="padding:6px 0 2px;font-size:12.5px"><b style="color:var(--tx)">${nSel} marcado${nSel > 1 ? 's' : ''}</b> · ${gb(marcables.filter(c => c.sel).reduce((s, c) => s + c.bytes, 0))} → ≈ ${gb(marcables.filter(c => c.sel).reduce((s, c) => s + c.estimado, 0))}</div>`;
|
||
} else if (busca || !CAND.length) html += `<div class="marcar"><input type="search" id="convBusca" placeholder="buscar por nombre o carpeta…" value="${esc(CONV_BUSCA)}" aria-label="buscar clip"></div>`;
|
||
html += `<div class="convbar">${CAND.length ? `<label style="display:flex;gap:6px;align-items:center;font-weight:500"><input type="checkbox" id="candTodos" ${marcables.length && CAND.filter(c => c.sel).length === marcables.length ? 'checked' : ''} ${marcables.length ? '' : 'disabled'}> marcar todos los válidos</label>` : ''}<span class="sep"></span><span class="mut">ver</span>${VER.filter(x => x[2]).map(([k, t, n]) => `<button class="lnk ${CONV_VER === k ? 'on' : ''}" data-ver="${k}">${t} · ${n}</button>`).join('')}<span class="mut">ordenar por</span>${ORD.map(([k, t]) => `<button class="lnk ${CONV_ORDEN.campo === k ? 'on' : ''}" data-orden="${k}" title="${CONV_ORDEN.campo === k ? 'otra vez para invertir el orden' : ''}">${t}${CONV_ORDEN.campo === k ? (CONV_ORDEN.inverso ? ' ↑' : ' ↓') : ''}</button>`).join('')}</div>`;
|
||
const tope = lista.slice(0, 800);
|
||
if (CONV_ORDEN.campo === 'carpeta') {
|
||
// por carpetas: la ruta una sola vez, y en cada fila solo el nombre del clip
|
||
const gs = []; tope.forEach(f => { const k = carpeta(f); let g = gs.find(x => x.k === k); if (!g) { g = {k, filas: []}; gs.push(g); } g.filas.push(f); });
|
||
// primero las carpetas con algo que hacer (la que se convierte, las que esperan, las convertibles, las
|
||
// terminadas) y al final las que solo tienen excluidos; a igual cosa, por nombre
|
||
const rangoG = g => Math.min(...g.filas.map(rango));
|
||
gs.sort((a, b) => (rangoG(a) - rangoG(b)) * (CONV_ORDEN.inverso ? -1 : 1) || a.k.localeCompare(b.k, 'es') * (CONV_ORDEN.inverso ? -1 : 1));
|
||
html += gs.map((g, gi) => {
|
||
const marc = g.filas.filter(f => f.c && !f.c.excluido && !f.c.en_cola), todos = marc.length && marc.every(f => f.c.sel);
|
||
const corre = g.filas.some(f => f.t && f.t.estado === 'corriendo'), enCola = g.filas.filter(f => grupo(f) === 'cola').length, hechos = g.filas.filter(f => grupo(f) === 'hechos').length;
|
||
const plegada = CONV_PLEGADAS.has(g.k), bIn = g.filas.reduce((s, f) => s + bytes(f), 0), bOut = g.filas.reduce((s, f) => s + sale(f), 0);
|
||
return `<div class="cgrupo ${gi ? '' : 'primero'}"><input type="checkbox" data-grupo="${esc(g.k)}" ${todos ? 'checked' : ''} ${marc.length ? '' : 'disabled'} aria-label="marcar la carpeta"><button class="pl" data-plegar="${esc(g.k)}" aria-label="${plegada ? 'desplegar' : 'plegar'}">${plegada ? '▸' : '▾'}</button><b>${esc((g.k || '').split(/[\\/]/).filter(Boolean).join(' › ') || '(raíz)')}</b><span class="mut">${g.filas.length} clip${g.filas.length > 1 ? 's' : ''} · ${gb(bIn)}${bOut ? ' → ' + (g.filas.some(f => f.t && f.t.bytes_out) ? '' : '≈ ') + gb(bOut) : ''}</span><span class="sep"></span><span class="cab ${corre ? 'corre' : ''}">${corre ? 'convirtiendo' : enCola ? `en cola · ${enCola}` : hechos ? `terminados · ${hechos}` : ''}</span></div>` + (plegada ? '' : g.filas.map(f => filaConv(f, false)).join(''));
|
||
}).join('');
|
||
} else html += tope.map(f => filaConv(f, true)).join('');
|
||
html += (lista.length > 800 ? `<div class="mut" style="padding:8px 0">… y ${lista.length - 800} más: usa un filtro de «ver»</div>` : '') + (!lista.length ? '<div class="mut" style="padding:8px 0">Nada en este grupo.</div>' : '');
|
||
// los excluidos, en grupos y con su porqué
|
||
const exc = {}; CAND.filter(c => c.excluido).forEach(c => { const k = claveExc(c.excluido); exc[k] = (exc[k] || 0) + 1; });
|
||
const ks = Object.keys(exc).sort((a, b) => exc[b] - exc[a]);
|
||
if (ks.length) html += `<div class="excl"><b style="color:var(--tx)">Excluidos · ${cuenta.no || 0}</b><span>no se ofrecen, y por qué:</span>${ks.map(k => `<button data-exc="${esc(k)}" class="${CONV_EXC === k ? 'on' : ''} ${k === 'no se pudieron leer' ? 'ojo' : ''}">${exc[k]} ${esc(k)}</button>`).join('')}${CONV_EXC ? '<button class="lnk" data-exc="" style="background:none;border:0">ver todos</button>' : ''}</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; CONV_CRITERIO = ''; resumenCand(); });
|
||
const ct = $('#candTodos'); if (ct) ct.onchange = e => { CAND.forEach(c => c.sel = e.target.checked && !c.excluido && !c.en_cola); cargarCola(); };
|
||
$$('#convLista [data-grupo]').forEach(g => g.onchange = e => { CAND.forEach(c => { if (padreDe(c.rel) === g.dataset.grupo && !c.excluido && !c.en_cola) c.sel = e.target.checked; }); cargarCola(); });
|
||
$$('#convLista [data-plegar]').forEach(b => b.onclick = () => { const k = b.dataset.plegar; if (CONV_PLEGADAS.has(k)) CONV_PLEGADAS.delete(k); else CONV_PLEGADAS.add(k); cargarCola(); });
|
||
$$('#convLista [data-ver]').forEach(b => b.onclick = () => { CONV_VER = b.dataset.ver; if (CONV_VER !== 'no') CONV_EXC = ''; cargarCola(); });
|
||
$$('#convLista [data-exc]').forEach(b => b.onclick = () => { CONV_EXC = b.dataset.exc; CONV_VER = CONV_EXC ? 'no' : 'todos'; cargarCola(); });
|
||
$$('#convLista [data-orden]').forEach(b => b.onclick = () => { if (CONV_ORDEN.campo === b.dataset.orden) CONV_ORDEN.inverso = !CONV_ORDEN.inverso; else CONV_ORDEN = {campo: b.dataset.orden, inverso: false}; cargarCola(); });
|
||
$$('#convLista [data-primero]').forEach(b => b.onclick = () => accion(b, async () => { await api('/api/optimizar/control', {accion: 'primero', id: +b.dataset.primero}); await cargarCola(); }));
|
||
$$('#convLista [data-comparar]').forEach(b => b.onclick = () => comparar(+b.dataset.comparar));
|
||
$$('#convLista [data-criterio]').forEach(b => b.onclick = () => { const k = b.dataset.criterio, marc = CAND.filter(c => !c.excluido && !c.en_cola), cr = criteriosDe(marc).find(x => x.k === k); CONV_CRITERIO = k === 'ninguno' ? '' : k; marc.forEach(c => c.sel = cr ? cr.p(c) : false); cargarCola(); });
|
||
const bq = $('#convBusca'); if (bq) { bq.oninput = () => { CONV_BUSCA = bq.value; clearTimeout(bq._t); bq._t = setTimeout(() => { const pos = bq.selectionStart; cargarCola().then(() => { const b2 = $('#convBusca'); if (b2) { b2.focus(); b2.setSelectionRange(pos, pos); } }); }, 350); }; }
|
||
}
|
||
// ---- cuándo trabaja la cola y qué mete sola (se guarda en optimizar.*)
|
||
function pintarAjustesCola(){
|
||
const o = (ESTADO.config && ESTADO.config.optimizar) || {}, h = o.horario || {}, a = o.aligerar || {};
|
||
const desde = h.desde || '20:00', hasta = h.hasta || '07:00';
|
||
const min = s => { const [hh, mm] = s.split(':').map(Number); return hh * 60 + mm; };
|
||
const d1 = min(desde), d2 = min(hasta), seg = d1 < d2 ? [[0, d1, 0], [d1, d2, 1], [d2, 1440, 0]] : [[0, d2, 1], [d2, d1, 0], [d1, 1440, 1]];
|
||
$('#colaAjustesCuerpo').innerHTML = `
|
||
<div class="caja"><label class="t"><input type="checkbox" id="ajHorario" ${h.activo ? 'checked' : ''}> Convertir solo en un horario</label>
|
||
<div class="f"><span>de</span><input type="time" id="ajDesde" value="${esc(desde)}" aria-label="desde"><span>a</span><input type="time" id="ajHasta" value="${esc(hasta)}" aria-label="hasta"><span>· fuera de ese horario la cola espera entre clip y clip, sin perder lo que iba</span></div>
|
||
<div class="dia" aria-hidden="true">${seg.map(([x, y, on]) => `<i style="flex:${Math.max(1, y - x)};background:${on ? 'var(--acc)' : 'var(--panel2)'}"></i>`).join('')}</div>
|
||
<div class="f" style="justify-content:space-between"><span>0 h</span><span>12 h</span><span>24 h</span></div></div>
|
||
<div class="caja"><label class="t"><input type="checkbox" id="ajPausa" ${o.pausar_si_edito === false ? '' : 'checked'}> Esperar si estoy editando</label>
|
||
<div class="d">Si DaVinci Resolve, Premiere o After Effects están usando la tarjeta, la cola no empieza el siguiente clip hasta que la dejen. El que va se termina.</div>
|
||
<div class="apps"><span>DaVinci Resolve</span><span>Premiere Pro</span><span>After Effects</span></div></div>
|
||
<div class="caja"><label class="t"><input type="checkbox" id="ajAligerar" ${a.activo ? 'checked' : ''}> Aligerar solo lo viejo</label>
|
||
<div class="f"><span>en la pasada automática, mete en cola lo de más de</span><input type="number" id="ajMeses" value="${+a.meses || 6}" min="1" style="width:56px" aria-label="meses"><span>meses, hasta</span><input type="number" id="ajGb" value="${+a.gb_noche || 100}" min="1" style="width:70px" aria-label="GB por pasada"><span>GB por pasada</span></div>
|
||
<div class="d">Va con el orden automático de la pestaña Carpeta (la tarea de Windows de cada noche). Convierte y verifica; <b style="color:var(--tx)">no reemplaza</b>, salvo que marques «y reemplazar solo cuando estén verificados».</div></div>
|
||
<div class="caja"><label class="t"><input type="checkbox" id="ajAvisar" ${o.avisar === false ? '' : 'checked'}> Avisar al terminar</label>
|
||
<div class="d">Un aviso de Windows cuando acabe la tanda, o si falla, aunque la ventana esté cerrada.</div>
|
||
<div style="border:1px solid var(--line);border-radius:8px;padding:8px 12px;display:flex;gap:10px;align-items:center;background:var(--panel2);max-width:420px;font-size:12.5px"><span style="width:26px;height:26px;border-radius:7px;background:var(--acc);flex:none"></span><span><b>OrganiLAB terminó</b><br>212 clips convertidos y verificados en 7 h 12 min. Falta reemplazar.</span></div></div>`;
|
||
const guardar = seguro(async () => {
|
||
const parcial = {horario: {activo: $('#ajHorario').checked, desde: $('#ajDesde').value || '20:00', hasta: $('#ajHasta').value || '07:00'},
|
||
pausar_si_edito: $('#ajPausa').checked, aligerar: {activo: $('#ajAligerar').checked, meses: +$('#ajMeses').value || 6, gb_noche: +$('#ajGb').value || 100},
|
||
avisar: $('#ajAvisar').checked};
|
||
await api('/api/config', {optimizar: parcial});
|
||
Object.assign(ESTADO.config.optimizar, parcial);
|
||
pintarAjustesCola();
|
||
});
|
||
$$('#colaAjustesCuerpo input').forEach(i => i.onchange = guardar);
|
||
}
|
||
// ---- comparar: el mismo cuadro en el original y en el convertido, con lupa, y la nota de calidad
|
||
function comparar(id){
|
||
const t = COLA_TS.find(x => x.id === id); if (!t) return;
|
||
const durC = t.dur || 0, nom = (t.ruta || '').split(/[\\/]/).pop();
|
||
const puntos = durC > 2 ? [0.1, 0.5, 0.9].map(p => Math.round(durC * p * 10) / 10) : [0];
|
||
let tAct = puntos[Math.floor(puntos.length / 2)];
|
||
const src = cual => `/api/optimizar/cuadro?id=${id}&cual=${cual}&t=${tAct}`;
|
||
const lado = (cual, tit, det) => `<div class="lado"><div style="display:flex;gap:8px;align-items:baseline"><b>${tit}</b><span class="mut" style="font-size:12px">${det}</span></div><div class="marco" data-lado="${cual}"><img src="${src(cual)}" alt="${tit}: cuadro en el segundo ${tAct}"><div class="lupa"></div></div></div>`;
|
||
const notaHtml = () => t.nota ? `<b class="n ${t.nota < 93 ? 'baja' : ''}">${t.nota} <span style="font-size:13px;color:var(--mut);font-weight:500">/ 100</span></b><span class="mut">VMAF sobre unos 10 s del medio del clip · el cuadro peor, ${t.nota_min}. De 93 arriba no se nota a simple vista.</span>` : `<button class="p" id="cmpMedir">Medir la calidad</button><span class="mut">compara unos 10 s del medio del clip con el original; tarda entre medio minuto y dos.</span>`;
|
||
const fm = (ESTADO.formatos || {})[t.formato || 'h265_10bit'] || {};
|
||
modal(`Comparar: ${nom}`, `<div class="cmp">${lado('original', 'Original', `${gb(t.bytes_in)}${t.motor === 'braw' ? ' · BRAW revelado con la misma curva' : ''}`)}${lado('convertido', 'Convertido', `${t.bytes_out ? gb(t.bytes_out) + ' · ' : ''}${esc((fm.nombre || '').split(' · ')[0])}`)}</div>
|
||
<div class="cmpt"><span>cuadro en el segundo</span>${puntos.map(p => `<button data-t="${p}" class="${p === tAct ? 'on' : ''}">${p}</button>`).join('')}<span style="margin-left:8px">pasa el ratón por la imagen: la lupa amplía el mismo recorte en los dos</span></div>
|
||
<div class="cmpnota"><div><span class="cabsec" style="margin:0">Nota de calidad</span><div id="cmpNota" style="display:flex;flex-direction:column;gap:4px">${notaHtml()}</div></div><div><span class="cabsec" style="margin:0">Se conserva</span><span>${durC ? tiempoLargo(durC) + ' · ' : ''}${t.fps ? t.fps + ' fps · ' : ''}código de tiempo · audio · etiquetas de la cámara · ${fm.nombre && fm.nombre.includes('10') ? '10 bits de color' : 'el color del original'}</span></div><div>${t.estado === 'verificado' && !(t.formato || '').startsWith('prores') ? `<button class="ok" id="cmpReemplazar">Reemplazar este original</button><span class="mut">va a _Para borrar/originales; se deshace desde Historial</span>` : t.estado === 'reemplazado' ? '<span class="mut">El original ya se reemplazó: está en _Para borrar/originales.</span>' : '<span class="mut">Cuando esté verificado se podrá reemplazar desde aquí.</span>'}</div></div>`, null);
|
||
$('#modal').classList.add('ancho');
|
||
const marcos = $$('#modalCuerpo .marco');
|
||
const lupa = (ev) => {
|
||
const m = ev.currentTarget, img = m.querySelector('img'), r = img.getBoundingClientRect(), Z = 3;
|
||
const rx = Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)), ry = Math.max(0, Math.min(1, (ev.clientY - r.top) / r.height));
|
||
marcos.forEach(mm => { const im = mm.querySelector('img'), rr = im.getBoundingClientRect(), l = mm.querySelector('.lupa'); mm.classList.add('con'); l.style.backgroundImage = `url("${im.src}")`; l.style.backgroundSize = `${rr.width * Z}px ${rr.height * Z}px`; l.style.backgroundPosition = `${-(rx * rr.width * Z - 110)}px ${-(ry * rr.height * Z - 110)}px`; l.style.left = `${Math.max(0, Math.min(rr.width - 220, rx * rr.width - 110))}px`; l.style.top = `${Math.max(0, Math.min(rr.height - 220, ry * rr.height - 110))}px`; });
|
||
};
|
||
marcos.forEach(m => { m.onmousemove = lupa; m.onmouseleave = () => marcos.forEach(mm => mm.classList.remove('con')); const im = m.querySelector('img'); im.onerror = () => { m.innerHTML = `<span style="padding:12px;text-align:center">no se pudo sacar el cuadro ${m.dataset.lado === 'original' ? 'del original' : 'del convertido'}</span>`; }; });
|
||
$$('#modalCuerpo [data-t]').forEach(b => b.onclick = () => { tAct = +b.dataset.t; $$('#modalCuerpo [data-t]').forEach(x => x.classList.toggle('on', x === b)); marcos.forEach(m => { const im = m.querySelector('img'); if (im) im.src = src(m.dataset.lado); }); });
|
||
const bm = $('#cmpMedir'); if (bm) bm.onclick = () => accion(bm, async () => { $('#cmpNota').innerHTML = '<span class="spin"></span> midiendo…'; try { const r = await api('/api/optimizar/calidad', {id}); t.nota = r.vmaf; t.nota_min = r.minimo; $('#cmpNota').innerHTML = notaHtml(); } catch (e) { $('#cmpNota').innerHTML = `<span class="bad">${esc(e.message)}</span>`; } });
|
||
const br = $('#cmpReemplazar'); if (br) br.onclick = () => accion(br, async () => { const r = await api('/api/optimizar/reemplazar', {ids: [id]}); const x = r.resultados[0] || {}; aviso(x.ok ? `Reemplazado: ${x.motivo}` : `No se reemplazó: ${x.motivo}`, x.ok ? 'ok' : 'bad', x.ok ? {texto: 'Ver historial', ir: () => irA('historial')} : null); $('#modal').classList.remove('on'); cargarCola(); });
|
||
}
|
||
$('#btnColaToggle').onclick = () => accion($('#btnColaToggle'), async () => { await api('/api/optimizar/control', {accion: $('#btnColaToggle').textContent === 'Detener' ? 'detener' : 'iniciar'}); await cargarEstado(); cargarCola(); });
|
||
const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/cola'); const raiz = raizActual().toLowerCase(); return d.trabajos.filter(t => est.includes(t.estado) && (t.raiz || '').toLowerCase() === raiz && !(est.includes('verificado') && (t.formato || '').startsWith('prores'))).map(t => t.id); };
|
||
$('#btnVerificar').onclick = () => accion($('#btnVerificar'), async () => { const ids = await idsPorEstado('listo'); const r = await api('/api/optimizar/verificar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} verificados.`, ok === r.resultados.length ? 'ok' : 'bad'); if (ok < r.resultados.length) modal('Verificación', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'FALLA'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); });
|
||
$('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `<p><b>${ids.length}</b> originales se apartan a <span class="path">_Para borrar/originales</span> dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.</p><p>Cuando estés seguro, una persona vacía _Para borrar a mano.</p>`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; });
|
||
$('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); });
|
||
$('#btnQuitar').onclick = () => accion($('#btnQuitar'), async () => { const ids = await idsPorEstado('error', 'cancelado', 'reemplazado', 'descartado'); const r = await api('/api/optimizar/control', {accion: 'quitar', ids}); const ap = r.apartados || {}; if (ap.movidos) aviso(`${ap.movidos} archivo${ap.movidos > 1 ? 's' : ''} de conversiones que no compensaban, apartado${ap.movidos > 1 ? 's' : ''} a _Para borrar\\descartados. Se deshace desde Historial.`, 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); if ((ap.errores || []).length) aviso(`No se pudieron apartar ${ap.errores.length}: ${ap.errores[0]}`, 'bad'); cargarCola(); });
|
||
// Con la cola parada, las que esperan también se pueden quitar (antes no había forma desde la interfaz, y
|
||
// bloqueaban Desconectar). No toca archivos: solo las saca de la cola.
|
||
$('#btnQuitarEspera').onclick = () => accion($('#btnQuitarEspera'), async () => { const ids = await idsPorEstado('pendiente'); if (!ids.length) throw new ApiError('No hay conversiones en espera de esta carpeta.'); modal('Quitar las conversiones en espera', `<p><b>${ids.length}</b> conversión${ids.length > 1 ? 'es' : ''} en espera se quita${ids.length > 1 ? 'n' : ''} de la cola. No se toca ningún archivo: los originales siguen donde están y puedes volver a encolarlas desde «Buscar clips».</p>`, () => accion($('#btnQuitarEspera'), async () => { await api('/api/optimizar/control', {accion: 'quitar', ids}); await cargarEstado(); cargarCola(); })); $('#modalSi').textContent = 'Quitar'; });
|
||
|
||
// ---------------- respaldar ----------------
|
||
function pintarRespaldo(){
|
||
const e = ESTADO.respaldo || {};
|
||
$('#btnRespDetener').disabled = e.estado !== 'corriendo';
|
||
const cv = CARPETA && CARPETA.convertir; const rawSin = cv && cv.raw_bytes && !((cv.trabajos || {}).reemplazado || {}).n;
|
||
$('#respAviso').innerHTML = rawSin ? `<div class="hint warn">Esta carpeta tiene ${gb(cv.raw_bytes)} en BRAW sin convertir. El respaldo compacto <b>no copia originales de cámara</b> salvo que marques «incluir BRAW / RAW sin convertir». Si quieres el respaldo ligero, pasa antes por <b>Convertir a 10 bits</b>.</div>` : '';
|
||
if (!e.estado) return;
|
||
const pct = e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : 0;
|
||
$('#respBarra').style.width = pct + '%';
|
||
const vel = e.inicio && e.bytes ? e.bytes / Math.max(1, (Date.now()/1000 - e.inicio)) : 0;
|
||
$('#respEstado').innerHTML = e.estado === 'corriendo' ? `<span class="spin"></span> ${e.hechos}/${e.total} · ${gb(e.bytes)} de ${gb(e.bytes_total)} · ${gb(vel)}/s · <span class="cell-small">${esc((e.actual||'').slice(-70))}</span>` : `${esc(e.estado)}: ${e.copiados} copiados, ${e.saltados} ya estaban, ${e.errores_n} errores → ${esc(e.destino)}`;
|
||
if (e.errores_n) api('/api/respaldo/estado').then(x => { $('#respErrores').textContent = x.errores.join('\n'); }).catch(() => {}); else $('#respErrores').textContent = 'ninguno';
|
||
}
|
||
$('#btnRespCalc').onclick = () => accion($('#btnRespCalc'), async () => {
|
||
const dest = $('#respDestino').value.trim(); if (!dest) throw new ApiError('Indica la carpeta de destino');
|
||
$('#respEstado').innerHTML = '<span class="spin"></span> calculando…';
|
||
RESP = await api(`/api/respaldo/plan?raiz=${encodeURIComponent(raizActual())}&destino=${encodeURIComponent(dest)}&por_tipo=${$('#respTipo').checked ? 1 : 0}&sin_stock=${$('#respSinStock').checked ? 1 : 0}&incluir_raw=${$('#respRaw').checked ? 1 : 0}&meses=${$('#respMeses').value}`);
|
||
RESP.raiz = raizActual();
|
||
$('#respEstado').textContent = `se copiaría a ${RESP.base}`;
|
||
$('#respCards').innerHTML = [[num(RESP.n), 'archivos a copiar'], [gb(RESP.bytes), 'peso del respaldo'], [RESP.convertidos, 'clips ya en H.265 10 bits'], [gb(RESP.raw_omitidos.bytes), `originales de cámara NO copiados (${RESP.raw_omitidos.n})`], [gb(RESP.stock_omitido.bytes), `stock de Envato omitido (${RESP.stock_omitido.n})`]].concat(RESP.reciente_omitido && RESP.reciente_omitido.meses ? [[gb(RESP.reciente_omitido.bytes), `de menos de ${RESP.reciente_omitido.meses} meses, no se copia aún (${num(RESP.reciente_omitido.n)})`]] : []).map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join('');
|
||
$('#respTipos').innerHTML = `<table>${RESP.por_tipo.map(t => `<tr><td>${esc(t.tipo)}</td><td class="num">${num(t.n)}</td><td class="num">${gb(t.bytes)}</td></tr>`).join('')}</table>`;
|
||
$('#respRawLista').innerHTML = RESP.raw_omitidos.n ? `<ul class="ejemplos" style="margin:0;padding-left:16px">${RESP.raw_omitidos.ejemplos.map(x => `<li>${esc(x.rel)} · ${gb(x.bytes)}</li>`).join('')}${RESP.raw_omitidos.n > 15 ? `<li>… y ${RESP.raw_omitidos.n - 15} más</li>` : ''}</ul>` : '<span class="ok">Ninguno: todo lo de cámara ya tiene su versión 10 bits (o se incluye tal cual).</span>';
|
||
$('#respMuestra').innerHTML = RESP.muestra.map(m => `${esc(m.de)} <span class="arrow">→</span> ${esc(m.a)}`).join('<br>');
|
||
bloquear('btnRespCopiar', RESP.n ? '' : 'No hay nada que copiar');
|
||
if (CARPETA) { CARPETA.respaldo.plan = {n: RESP.n, bytes: RESP.bytes}; pintarIndice(); }
|
||
});
|
||
$('#btnRespCopiar').onclick = () => { if (!RESP || RESP.raiz !== raizActual()) return aviso('Calcula el respaldo de esta carpeta primero.', 'bad'); modal('Copiar respaldo', `<p>Se copian <b>${num(RESP.n)}</b> archivos (${gb(RESP.bytes)}) a <span class="path">${esc(RESP.base)}</span>. Lo que ya esté igual se salta. Puedes detenerlo y retomarlo luego.</p>`, () => accion($('#btnRespCopiar'), async () => { await api('/api/respaldo/copiar', {raiz: raizActual()}); aviso('Copia en marcha. El avance está en la barra de abajo.', 'ok'); await cargarEstado(); pintarRespaldo(); })); $('#modalSi').textContent = 'Copiar'; };
|
||
$('#btnRespDetener').onclick = () => accion($('#btnRespDetener'), async () => { await api('/api/respaldo/detener', {}); });
|
||
|
||
// ---------------- historial ----------------
|
||
async function cargarBit(){
|
||
const [d, p] = await Promise.all([api('/api/bitacora'), raizActual() ? api('/api/papelera?raiz=' + encodeURIComponent(raizActual())) : null]);
|
||
const raiz = raizActual().toLowerCase(); const solo = $('#bitSoloRaiz').checked;
|
||
const lotes = d.lotes.filter(l => !solo || !raiz || (l.raiz || '').toLowerCase() === raiz);
|
||
const pl = $('#papeleraLinea');
|
||
if (p && p.subcarpetas && p.subcarpetas.length) { pl.style.display = ''; pl.innerHTML = `Apartado en <span class="path">_Para borrar</span>: <b>${gb(p.bytes)}</b> en ${p.subcarpetas.map(s => `${esc(s.nombre)} (${gb(s.bytes)})`).join(', ')}. La app no lo borra: lo revisa y lo borra una persona. <button class="lnk" id="btnAbrirPap">Abrir en el Explorador</button>`; $('#btnAbrirPap').onclick = () => accion($('#btnAbrirPap'), async () => { const r = await api('/api/abrir', {ruta: p.carpeta}); if (r.ok === false) throw new ApiError(r.motivo || 'No se pudo abrir'); }); }
|
||
else pl.style.display = 'none';
|
||
const dias = {}; lotes.forEach(l => { const k = l.ts ? new Date(l.ts * 1000).toDateString() : '?'; (dias[k] = dias[k] || []).push(l); });
|
||
const hoy = new Date().toDateString(), ayer = new Date(Date.now() - 86400000).toDateString();
|
||
const titulo = k => k === hoy ? 'Hoy' : k === ayer ? 'Ayer' : new Date(k).toLocaleDateString('es-CO', {weekday: 'long', day: 'numeric', month: 'long'});
|
||
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
|
||
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
|
||
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
|
||
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${cuentaLote(l)}${!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 || '').startsWith('entrada-') || (l.lote || '').startsWith('tanda-') ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
|
||
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
|
||
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
|
||
}
|
||
$('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad'));
|
||
|
||
// ---------------- ajustes ----------------
|
||
let AJ = null, CLI_ABIERTO = null;
|
||
function pintarAjustes(){
|
||
const ac = $('#actAuto');
|
||
ac.checked = (ESTADO.config.actualizaciones || {}).auto === true;
|
||
ac.onchange = () => api('/api/config', {actualizaciones: {auto: ac.checked}}).then(cargarEstado).then(() => aviso(ac.checked ? 'Al arrancar consultará si hay versión nueva.' : 'Ya no sale a la red al arrancar.', 'ok')).catch(e => aviso(e.message, 'bad'));
|
||
if (!AJ) {
|
||
const c = ESTADO.config; AJ = {clientes: []};
|
||
Object.entries(c.clientes).forEach(([k, v]) => AJ.clientes.push({nombre: k, orig: k, tipo: v.tipo, alias: (v.alias || []).join('; '), rutas: (c.raices || []).filter(r => r.cliente === k).map(r => r.ruta)}));
|
||
(c.raices || []).filter(r => !c.clientes[r.cliente]).forEach(r => { let cl = AJ.clientes.find(x => x.nombre === r.cliente); if (!cl) { cl = {nombre: r.cliente, orig: r.cliente, tipo: r.tipo, alias: '', rutas: []}; AJ.clientes.push(cl); } cl.rutas.push(r.ruta); });
|
||
AJ.clientes.sort((a, b) => (b.rutas.length - a.rutas.length) || a.nombre.localeCompare(b.nombre));
|
||
}
|
||
const f = ($('#filtroCli').value || '').toLowerCase();
|
||
const tipos = t => Object.entries(ESTADO.tipos).map(([k,v]) => `<option value="${k}" ${k===t?'selected':''} title="${esc(v)}">${k}</option>`).join('');
|
||
$('#clientes').innerHTML = AJ.clientes.map((c, i) => ({c, i})).filter(({c}) => !f || c.nombre.toLowerCase().includes(f) || c.alias.toLowerCase().includes(f) || c.rutas.join(' ').toLowerCase().includes(f)).map(({c, i}) => `<div class="cli ${CLI_ABIERTO === i ? 'abierto' : ''}" data-i="${i}"><div class="cab2"><span class="caret">${CLI_ABIERTO === i ? '▾' : '▸'}</span><b>${esc(c.nombre) || '<span class="mut">nuevo cliente</span>'}</b><span class="tipo">${esc(c.tipo)}</span><span class="m">${c.rutas.length ? `${c.rutas.length} carpeta${c.rutas.length > 1 ? 's' : ''} · ${esc(c.rutas[0])}${c.rutas.length > 1 ? ' …' : ''}` : 'sin carpeta'} · ${c.alias.split(';').filter(x => x.trim()).length} alias</span></div><div class="cuerpo"><div class="f"><label>Nombre</label><input type="text" value="${esc(c.nombre)}" data-k="nombre"><label>Tipo de trabajo</label><select data-k="tipo">${tipos(c.tipo)}</select><label>Sus carpetas<br><span class="cell-small">una por línea</span></label><textarea data-k="rutas" placeholder="(sin carpeta registrada: pega aquí la ruta)">${esc(c.rutas.join('\n'))}</textarea><label>Otros nombres<br><span class="cell-small">separados por ;</span></label><input type="text" value="${esc(c.alias)}" data-k="alias" placeholder="DR. NOMBRE; nombre; Nombre Apellido"></div><div class="toolbar" style="margin:10px 0 0"><button class="p" data-guardar="${i}">Guardar</button><button class="bad" data-quitar="${i}">Quitar</button><span class="sep"></span><span class="mut" style="font-size:12px">${esc(ESTADO.tipos[c.tipo] || '')}</span></div></div></div>`).join('') || '<div class="mut">Ningún cliente con ese nombre.</div>';
|
||
$$('#clientes .cab2').forEach(el => el.onclick = () => { const i = +el.parentElement.dataset.i; leerAjustes(); CLI_ABIERTO = CLI_ABIERTO === i ? null : i; pintarAjustes(); });
|
||
$$('#clientes [data-guardar]').forEach(b => b.onclick = () => accion(b, async () => { leerAjustes(); await guardarClientes(); }));
|
||
$$('#clientes [data-quitar]').forEach(b => b.onclick = () => { leerAjustes(); const c = AJ.clientes[+b.dataset.quitar]; modal('Quitar cliente', `<p>Se quita <b>${esc(c.nombre)}</b> de la lista y sus ${c.rutas.length} carpeta${c.rutas.length === 1 ? '' : 's'} dejan de estar registradas. <b>No se toca ningún archivo del disco.</b></p>`, seguro(async () => { AJ.clientes.splice(+b.dataset.quitar, 1); CLI_ABIERTO = null; await guardarClientes(); })); });
|
||
$('#plantillas').innerHTML = Object.entries(ESTADO.plantillas).map(([tipo, p]) => `<details ${tipo==='contenido'?'open':''}><summary><b>${tipo}</b> · ${esc(ESTADO.tipos[tipo]||'')}</summary><table>${Object.entries(p).map(([b,v]) => `<tr><td style="width:140px"><span class="tag" style="color:${COLORES[b]||''};border-color:${COLORES[b]||''}">${esc(NOMBRE_CUBETA[b] || b)}</span></td><td><input type="text" data-t="${tipo}" data-b="${b}" value="${esc(v)}" style="width:100%" aria-label="${esc(b)}"></td></tr>`).join('')}</table></details>`).join('');
|
||
}
|
||
function leerAjustes(){
|
||
$$('#clientes .cli.abierto').forEach(el => { const c = AJ.clientes[+el.dataset.i]; if (!c) return; c.nombre = el.querySelector('[data-k=nombre]').value.trim(); c.tipo = el.querySelector('[data-k=tipo]').value; c.alias = el.querySelector('[data-k=alias]').value; c.rutas = el.querySelector('[data-k=rutas]').value.split('\n').map(x => x.trim().replace(/^"|"$/g, '')).filter(Boolean); });
|
||
}
|
||
async function guardarClientes(){
|
||
const clientes = {}, raices = [];
|
||
AJ.clientes.filter(c => c.nombre).forEach(c => { clientes[c.nombre] = {tipo: c.tipo, alias: c.alias.split(';').map(x => x.trim()).filter(Boolean)}; c.rutas.forEach(r => raices.push({ruta: r, cliente: c.nombre, tipo: c.tipo})); });
|
||
await api('/api/clientes', {clientes}); await api('/api/raices', {raices});
|
||
AJ = null; await cargarEstado(); pintarAjustes(); aviso('Guardado.', 'ok');
|
||
}
|
||
$('#filtroCli').oninput = () => { leerAjustes(); pintarAjustes(); }; // sin leer antes, el filtro borraba lo que estabas editando
|
||
$('#btnAddCliente').onclick = () => { leerAjustes(); AJ.clientes.unshift({nombre: '', orig: '', tipo: 'contenido', alias: '', rutas: []}); CLI_ABIERTO = 0; $('#filtroCli').value = ''; pintarAjustes(); const el = $('#clientes .cli.abierto [data-k=nombre]'); if (el) el.focus(); };
|
||
$('#btnGuardarPlantillas').onclick = () => accion($('#btnGuardarPlantillas'), async () => { const plantillas = {}; $$('#plantillas input').forEach(i => { plantillas[i.dataset.t] = plantillas[i.dataset.t] || {}; plantillas[i.dataset.t][i.dataset.b] = i.value.trim(); }); await api('/api/plantillas', {plantillas}); await cargarEstado(); PLAN = null; estadoPlan(); aviso('Plantillas guardadas', 'ok'); });
|
||
|
||
// ---------------- carpetas vacías ----------------
|
||
let VACIAS = null;
|
||
const cargarVacias = seguro(async () => {
|
||
const raiz = raizActual(); if (!raiz) return;
|
||
VACIAS = await api('/api/vacias?raiz=' + encodeURIComponent(raiz));
|
||
VACIAS.carpetas.forEach(c => c.sel = false);
|
||
$('#btnVaciasTodas').textContent = 'Marcar todas';
|
||
pintarVacias();
|
||
});
|
||
function pintarVacias(){
|
||
const caja = $('#vaciasCaja'), tb = $('#tablaVacias tbody');
|
||
if (!VACIAS) { caja.style.display = 'none'; $('#btnVaciasTodas').style.display = 'none'; $('#vaciasResumen').textContent = ''; $('#btnVaciasBorrar').disabled = true; $('#btnVaciasBorrarPorQue').textContent = 'Busca primero'; return; }
|
||
const hay = VACIAS.carpetas.length;
|
||
caja.style.display = hay ? '' : 'none';
|
||
$('#btnVaciasTodas').style.display = hay ? '' : 'none';
|
||
tb.innerHTML = VACIAS.carpetas.map((c, i) => `<tr><td><input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} aria-label="${esc(c.rel)}"></td><td class="path">${esc(c.rel)}</td><td class="num">${c.subcarpetas || ''}</td></tr>`).join('');
|
||
tb.querySelectorAll('input').forEach(i => i.onchange = () => { VACIAS.carpetas[+i.dataset.i].sel = i.checked; resumenVacias(); });
|
||
resumenVacias();
|
||
}
|
||
function resumenVacias(){
|
||
const n = VACIAS ? VACIAS.carpetas.filter(c => c.sel).length : 0;
|
||
$('#vaciasResumen').textContent = !VACIAS ? '' : VACIAS.carpetas.length
|
||
? `${VACIAS.carpetas.length} vacía${VACIAS.carpetas.length > 1 ? 's' : ''} · ${n} marcada${n === 1 ? '' : 's'}`
|
||
: 'No queda ninguna carpeta vacía en esta carpeta.';
|
||
$('#btnVaciasBorrar').disabled = !n;
|
||
$('#btnVaciasBorrarPorQue').textContent = !VACIAS ? 'Busca primero' : n ? '' : 'Marca alguna';
|
||
}
|
||
$('#btnVacias').onclick = () => accion($('#btnVacias'), cargarVacias);
|
||
$('#btnVaciasTodas').onclick = () => { if (!VACIAS) return; const todas = VACIAS.carpetas.every(c => c.sel); VACIAS.carpetas.forEach(c => c.sel = !todas); $('#btnVaciasTodas').textContent = todas ? 'Marcar todas' : 'Desmarcar todas'; pintarVacias(); };
|
||
$('#btnVaciasBorrar').onclick = () => {
|
||
const s = (VACIAS ? VACIAS.carpetas : []).filter(c => c.sel); if (!s.length) return;
|
||
if (VACIAS.raiz !== raizActual()) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad');
|
||
const dentro = s.reduce((a, c) => a + (c.subcarpetas || 0), 0);
|
||
modal('Borrar carpetas vacías', `<p><b>${s.length}</b> carpeta${s.length > 1 ? 's' : ''} sin un solo archivo dentro${dentro ? `, y ${dentro} subcarpeta${dentro > 1 ? 's' : ''} igual de vacía${dentro > 1 ? 's' : ''} que cuelga${dentro > 1 ? 'n' : ''} de ella${s.length > 1 ? 's' : ''}` : ''}, se borran del disco.</p><p>No hay nada dentro que perder. Lo único que se va es el nombre, y <b>Deshacer</b> las vuelve a crear tal cual:</p><div class="ejtipo">${s.slice(0, 8).map(c => `<div><b>${esc(c.rel)}</b></div>`).join('')}${s.length > 8 ? `<div><span>… y ${s.length - 8} más</span></div>` : ''}</div>`, () => accion($('#btnVaciasBorrar'), async () => {
|
||
const r = await api('/api/vacias/borrar', {raiz: raizActual(), rutas: s.map(c => c.ruta)});
|
||
aviso(`${r.borradas} carpeta${r.borradas === 1 ? '' : 's'} vacía${r.borradas === 1 ? '' : 's'} borrada${r.borradas === 1 ? '' : 's'}.` + (r.errores.length ? ` ${r.errores.length} se saltaron.` : ''),
|
||
r.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(r.lote)});
|
||
await cargarVacias(); await cargarEstado();
|
||
}));
|
||
$('#modalSi').textContent = 'Borrar';
|
||
};
|
||
|
||
// ---------------- mapa: cómo van a quedar las carpetas ----------------
|
||
const SEP = '\\';
|
||
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {}, MAPAE_DESTINO = '';
|
||
const cargarMapaEst = seguro(async (tipo) => {
|
||
const raiz = raizActual();
|
||
const sel = $('#mapaTipo');
|
||
if (!sel.options.length && ESTADO) sel.innerHTML = Object.entries(ESTADO.tipos).map(([k, v]) => `<option value="${k}">${esc(v)}</option>`).join('');
|
||
const q = [];
|
||
if (tipo || sel.value) q.push('tipo=' + encodeURIComponent(tipo || sel.value));
|
||
if (raiz) q.push('raiz=' + encodeURIComponent(raiz));
|
||
MAPAE = await api('/api/estructura' + (q.length ? '?' + q.join('&') : ''));
|
||
sel.value = MAPAE.tipo;
|
||
MAPAE_NODOS = {}; MAPAE_SEL = '';
|
||
anotarMapa(MAPAE.arbol, '');
|
||
pintarMapaEst();
|
||
});
|
||
function anotarMapa(n, base){
|
||
n._r = base; n._id = 'm' + Object.keys(MAPAE_NODOS).length; MAPAE_NODOS[n._id] = n;
|
||
(n.hijos || []).forEach(h => anotarMapa(h, (base ? base + SEP : '') + h.nombre));
|
||
}
|
||
function nodoHtml(n){
|
||
const cif = n.n ? `${num(n.n)} · ${gb(n.bytes)}` : '';
|
||
const que = [n.variable && ('Cambia con el material: ' + n.variable), n.nota].filter(Boolean).join('. ');
|
||
const hit = MAPAE_DESTINO && (n.cubetas || []).includes(MAPAE_DESTINO);
|
||
return `<div><div class="nd ${MAPAE_SEL === n._id ? 'sel' : ''} ${hit ? 'hit' : ''} ${n.n ? '' : 'vacio'} ${n.variable ? 'var' : ''}" data-id="${n._id}" tabindex="0" role="button"${que ? ` title="${esc(que)}"` : ''}>` +
|
||
`<i class="pt"${n.color ? ` style="background:${n.color}"` : ''}></i>` +
|
||
`<span class="nm">${esc(n.nombre)}${SEP}</span>` +
|
||
`${cif ? `<span class="cif">${cif}</span>` : ''}</div>` +
|
||
`${n.hijos && n.hijos.length ? `<div class="hijos">${n.hijos.map(nodoHtml).join('')}</div>` : ''}</div>`;
|
||
}
|
||
function pintarMapaEst(){
|
||
if (!MAPAE) return;
|
||
$('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo;
|
||
$('#mapaArbol').classList.remove('mut');
|
||
const cols = MAPAE.cubetas.filter(c => c.plantilla && c.n).sort((a, b) => b.bytes - a.bytes);
|
||
$('#mapaArbol').innerHTML = nodoHtml(MAPAE.arbol) +
|
||
'<div class="leyenda">Las carpetas <i>en cursiva</i> cambian con el material: el año, el mes, el nombre de la pieza o el programa. Toca cualquiera para ver qué guarda o cambiarle el nombre.</div>' +
|
||
(cols.length ? `<div class="leyenda-col">${cols.map(c => `<span><i style="background:${c.color}"></i>${esc(NOMBRE_CUBETA[c.clave] || c.titulo)}</span>`).join('')}</div>` : '');
|
||
$('#btnDespintar').style.display = MAPAE.pintado ? '' : 'none';
|
||
$('#btnPintar').textContent = MAPAE.pintado ? 'Volver a pintar' : 'Pintar las carpetas en Windows';
|
||
$('#mapaEjemplos').innerHTML = MAPAE.cubetas.filter(c => c.ejemplos.length).slice(0, 8)
|
||
.map(c => `<button data-ej="${esc(c.ejemplos[0])}">${esc(c.ejemplos[0])}</button>`).join('');
|
||
$$('#mapaEjemplos button').forEach(b => b.onclick = () => { $('#mapaArchivo').value = b.dataset.ej; probarArchivo(); });
|
||
$$('#mapaArbol .nd').forEach(el => {
|
||
const ir = () => { MAPAE_SEL = el.dataset.id; pintarMapaEst(); };
|
||
el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } };
|
||
});
|
||
const con = MAPAE.cubetas.filter(c => c.n).length;
|
||
$('#mapaResumen').textContent = MAPAE.raiz ? `${con} de ${MAPAE.cubetas.filter(c => c.asignable).length} tipos de material tienen algo hoy en esta carpeta` : 'Conecta una carpeta para ver cuánto material tiene cada sitio';
|
||
pintarMapaDetalle();
|
||
pintarSinSitio();
|
||
}
|
||
function pintarMapaDetalle(){
|
||
const caja = $('#mapaDetalle'), n = MAPAE_NODOS[MAPAE_SEL];
|
||
if (!n) { caja.className = 'det mut'; caja.innerHTML = 'Toca una carpeta del árbol para ver qué guarda y cambiar dónde va.'; return; }
|
||
caja.className = 'det';
|
||
const cubs = (n.cubetas || []).map(c => MAPAE.cubetas.find(x => x.clave === c)).filter(Boolean);
|
||
let h = `<h4>${esc(n.nombre)}</h4><div class="ruta">${esc((MAPAE.arbol.nombre || '') + SEP + n._r)}</div>`;
|
||
if (n.crudo && n.crudo.length && !n.variable)
|
||
h += `<div class="toolbar" style="margin:0 0 10px"><input type="text" id="mapaNombre" value="${esc(n.nombre)}" style="flex:1;min-width:150px" aria-label="Nombre de la carpeta"><button class="p" id="btnRenombrar">Cambiar el nombre</button></div>`;
|
||
if (n.variable) h += `<p class="mut" style="font-size:12.5px">El nombre cambia con el material: es ${esc(n.variable)}.</p>`;
|
||
if (n.nota) h += `<p class="mut" style="font-size:12.5px">${esc(n.nota.charAt(0).toUpperCase() + n.nota.slice(1))}.</p>`;
|
||
if (n.apps_de){
|
||
const c = MAPAE.cubetas.find(x => x.clave === n.apps_de);
|
||
if (c && c.apps.length) h += `<p style="margin:10px 0 0"><b>Una carpeta por programa:</b></p><div class="ejtipo">${c.apps.map(a => `<div><b>${esc(a.nombre)}</b><span>${num(a.n)} · ${gb(a.bytes)}</span></div>`).join('')}</div>`;
|
||
}
|
||
if (!cubs.length && !n.apps_de) h += '<p class="mut" style="font-size:12.5px">Aquí no cae nada directamente: es un nivel intermedio, lo que hay está en las carpetas de dentro.</p>';
|
||
cubs.forEach(c => {
|
||
h += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--line2)"><b>${esc(c.titulo)}</b>`;
|
||
h += `<ul>${c.guarda.map(g => `<li>${esc(g)}</li>`).join('')}</ul>`;
|
||
if (c.apps && c.apps.length) h += `<div class="ejtipo" style="margin-top:8px">${c.apps.map(a => `<div><b>${esc(a.nombre)}</b><span>${num(a.n)} · ${gb(a.bytes)}</span></div>`).join('')}</div>`;
|
||
h += `<p class="mut" style="font-size:12.5px;margin:8px 0 4px">${MAPAE.raiz ? (c.n ? `Hoy en esta carpeta: <b>${num(c.n)}</b> archivos · <b>${gb(c.bytes)}</b>.` : 'Hoy no tienes nada de esto en esta carpeta.') : ''}</p>`;
|
||
h += `<div class="toolbar" style="margin-top:6px"><input type="text" class="path" data-pl="${esc(c.clave)}" value="${esc(c.plantilla)}" style="flex:1;min-width:200px" aria-label="Dónde va ${esc(c.titulo)}">`;
|
||
h += `<button class="p" data-guardarpl="${esc(c.clave)}">Guardar</button>`;
|
||
if (c.defecto && c.plantilla !== c.defecto) h += `<button class="lnk" data-defecto="${esc(c.clave)}">Volver al de fábrica</button>`;
|
||
h += '</div></div>';
|
||
});
|
||
if (cubs.length) h += `<div class="vars" style="margin-top:10px">Puedes usar: ${Object.entries(MAPAE.variables).map(([k, v]) => `<div><code>{${k}}</code> — ${esc(v)}</div>`).join('')}</div>`;
|
||
caja.innerHTML = h;
|
||
engancharPlantillas(caja);
|
||
const bn = $('#btnRenombrar');
|
||
if (bn) {
|
||
const pedir = () => renombrarCarpeta(n, $('#mapaNombre').value.trim());
|
||
bn.onclick = pedir;
|
||
$('#mapaNombre').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); pedir(); } };
|
||
}
|
||
}
|
||
function pintarSinSitio(){
|
||
const sin = MAPAE.cubetas.filter(c => c.asignable && !c.plantilla);
|
||
$('#mapaSinSitio').style.display = sin.length ? '' : 'none';
|
||
if (!sin.length) return;
|
||
$('#mapaSinSitioLista').innerHTML = sin.map(c => `<div class="sinsitio"><b>${esc(c.titulo)}</b><span class="mut" style="font-size:12px">${c.n ? `${num(c.n)} · ${gb(c.bytes)}` : 'sin material hoy'}</span><input type="text" class="path" data-pl="${esc(c.clave)}" value="${esc(c.defecto)}" style="flex:1;min-width:200px" aria-label="Dónde va ${esc(c.titulo)}"><button class="p" data-guardarpl="${esc(c.clave)}">Añadir</button></div>`).join('');
|
||
engancharPlantillas($('#mapaSinSitioLista'));
|
||
}
|
||
function engancharPlantillas(caja){
|
||
caja.querySelectorAll('[data-guardarpl]').forEach(b => b.onclick = () => accion(b, async () => {
|
||
const clave = b.dataset.guardarpl;
|
||
const campo = caja.querySelector(`input[data-pl="${clave}"]`);
|
||
await guardarPlantilla(clave, campo.value.trim());
|
||
}));
|
||
caja.querySelectorAll('[data-defecto]').forEach(b => b.onclick = () => accion(b, async () => {
|
||
const c = MAPAE.cubetas.find(x => x.clave === b.dataset.defecto);
|
||
await guardarPlantilla(c.clave, c.defecto);
|
||
}));
|
||
}
|
||
async function guardarPlantilla(clave, valor){
|
||
await api('/api/plantillas', {plantillas: {[MAPAE.tipo]: {[clave]: valor}}});
|
||
PLAN = null; estadoPlan();
|
||
await cargarEstado();
|
||
// se vuelve a la MISMA carpeta por su ruta, no por su posicion: al cambiar la plantilla el arbol
|
||
// cambia de forma y el numero de nodo pasa a ser el de otra rama
|
||
const antes = (MAPAE_NODOS[MAPAE_SEL] || {})._r;
|
||
await cargarMapaEst(MAPAE.tipo);
|
||
const nodos = Object.values(MAPAE_NODOS);
|
||
// se salta a donde haya quedado lo que acabas de cambiar; si esa cubeta ya no cae en ninguna carpeta
|
||
// (la dejaste en blanco), se vuelve a la misma carpeta de antes
|
||
const vuelta = nodos.find(n => (n.cubetas || []).includes(clave)) || nodos.find(n => n._r === antes);
|
||
if (vuelta) { MAPAE_SEL = vuelta._id; pintarMapaEst(); }
|
||
aviso('Guardado. El plan se vuelve a calcular con el destino nuevo.', 'ok');
|
||
}
|
||
// accion() pinta un spinner dentro del elemento: en un <select> eso se llevaría por delante sus opciones
|
||
$('#mapaTipo').onchange = () => cargarMapaEst($('#mapaTipo').value);
|
||
|
||
// ---- renombrar una carpeta del mapa ----
|
||
const renombrarCarpeta = seguro(async (nodo, nombre) => {
|
||
if (!nombre || nombre === nodo.nombre) return;
|
||
const raiz = raizActual();
|
||
let existen = {n: 0, carpetas: []};
|
||
if (raiz) existen = await api(`/api/estructura/existen?raiz=${encodeURIComponent(raiz)}&ruta=${encodeURIComponent(JSON.stringify(nodo.crudo))}`);
|
||
const cuenta = existen.n;
|
||
modal('Cambiar el nombre de la carpeta', `<p>A partir de ahora, lo que iba a <span class="path">${esc(nodo.nombre)}</span> va a ir a <span class="path">${esc(nombre)}</span>.</p>` +
|
||
(cuenta ? `<label style="display:flex;gap:8px;align-items:flex-start;margin-top:10px;font-size:13px"><input type="checkbox" id="mDisco" checked style="margin-top:3px"><span>Cambiarle el nombre también a ${cuenta === 1 ? 'la carpeta que ya existe' : `las <b>${cuenta}</b> carpetas que ya existen`} con ese nombre. Es un movimiento normal: se deshace desde Historial.</span></label><div class="ejtipo" style="margin-top:8px">${existen.carpetas.slice(0, 6).map(c => `<div><b>${esc(c)}</b></div>`).join('')}${cuenta > 6 ? `<div><span>… y ${cuenta - 6} más</span></div>` : ''}</div>`
|
||
: '<p class="mut" style="font-size:12.5px">No hay ninguna carpeta con ese nombre en el disco todavía; solo cambia lo que la app hará de ahora en adelante.</p>'), () => accion($('#btnRenombrar') || $('#mapaTipo'), async () => {
|
||
const r = await api('/api/estructura/renombrar', {tipo: MAPAE.tipo, ruta: nodo.crudo, nombre,
|
||
raiz: raiz || null, en_disco: cuenta ? $('#mDisco').checked : false});
|
||
if (r.error) throw new ApiError(r.error);
|
||
PLAN = null; estadoPlan();
|
||
await cargarEstado();
|
||
await cargarMapaEst(MAPAE.tipo);
|
||
const vuelta = Object.values(MAPAE_NODOS).find(x => x.nombre === nombre);
|
||
if (vuelta) { MAPAE_SEL = vuelta._id; pintarMapaEst(); }
|
||
aviso(r.movidas ? `Ahora se llama «${nombre}»; ${r.movidas} carpeta${r.movidas > 1 ? 's' : ''} renombrada${r.movidas > 1 ? 's' : ''} en el disco.` : `Ahora se llama «${nombre}».`,
|
||
(r.errores || []).length ? 'bad' : 'ok', r.movidas ? {texto: 'Ver historial', ir: () => irA('historial')} : null);
|
||
}));
|
||
$('#modalSi').textContent = 'Cambiar el nombre';
|
||
});
|
||
|
||
// ---- probar dónde iría un archivo ----
|
||
let PROBAR_T = null;
|
||
const probarArchivo = seguro(async () => {
|
||
const nombre = $('#mapaArchivo').value.trim();
|
||
const caja = $('#mapaRespuesta');
|
||
if (!nombre) { caja.innerHTML = ''; MAPAE_DESTINO = ''; pintarMapaEst(); return; }
|
||
const d = await api(`/api/estructura/archivo?nombre=${encodeURIComponent(nombre)}&tipo=${encodeURIComponent(MAPAE ? MAPAE.tipo : 'contenido')}`);
|
||
if (d.error) { caja.innerHTML = `<p class="mut">${esc(d.error)}</p>`; return; }
|
||
const raizN = MAPAE && MAPAE.arbol ? MAPAE.arbol.nombre : '';
|
||
const fecha = d.por_fecha === 'mtime'
|
||
? 'el nombre no trae fecha, así que el mes lo saca de la fecha del archivo'
|
||
: `el nombre dice que es del ${d.fecha}`;
|
||
const dia = d.dia ? ` «${d.dia}» es el día del rodaje.` : '';
|
||
caja.innerHTML = `<div class="resp"><i class="pt" style="background:${d.color}"></i><div>` +
|
||
`<div><b>${esc(d.nombre)}</b> — ${esc(d.que)}${d.camara ? ` · fuente ${esc(d.camara)}` : ''}${d.app ? ` · proyecto de ${esc(d.app)}` : ''}</div>` +
|
||
`<div class="d" style="margin:4px 0">${esc(raizN + SEP + d.destino)}${SEP}</div>` +
|
||
`<div class="mut" style="font-size:12px">${esc(fecha)}.${esc(dia)} Si el archivo está dentro de una carpeta que la app reconoce, esa carpeta puede mandar sobre esto.</div>` +
|
||
'</div></div>';
|
||
MAPAE_DESTINO = d.cubeta;
|
||
pintarMapaEst();
|
||
const hit = $('#mapaArbol .nd.hit');
|
||
if (hit) hit.scrollIntoView({block: 'center', behavior: 'smooth'});
|
||
});
|
||
$('#mapaArchivo').oninput = () => { clearTimeout(PROBAR_T); PROBAR_T = setTimeout(probarArchivo, 250); };
|
||
|
||
// ---- colores en el Explorador ----
|
||
$('#btnPintar').onclick = () => {
|
||
const raiz = raizActual(); if (!raiz) return aviso('Conecta una carpeta primero.', 'bad');
|
||
modal('Pintar las carpetas en Windows', '<p>A cada carpeta se le pone un icono del color de lo que guarda, para verlo en el Explorador sin abrir la app.</p><p class="mut" style="font-size:12.5px">Windows no tiene «color de carpeta», así que se hace por el mismo camino que «Propiedades › Personalizar › Cambiar icono»: un <span class="path">desktop.ini</span> y los iconos en una carpeta oculta dentro del cliente. No se mueve ni se borra ningún archivo, y se quita con un clic. En algunos discos de red Windows no lo permite; si pasa, te lo digo y no dejo nada escrito.</p>',
|
||
() => accion($('#btnPintar'), async () => {
|
||
const r = await api('/api/colores/pintar', {raiz});
|
||
if (r.error) throw new ApiError(r.error);
|
||
aviso(`${r.pintadas} carpetas pintadas con ${r.colores} colores.` + (r.sin_marca ? ` ${r.sin_marca} no se pudieron: el disco no lo permite.` : '') + ' Si el Explorador ya estaba abierto, pulsa F5.', 'ok');
|
||
await cargarMapaEst(MAPAE.tipo);
|
||
}));
|
||
$('#modalSi').textContent = 'Pintar';
|
||
};
|
||
$('#btnDespintar').onclick = () => accion($('#btnDespintar'), async () => {
|
||
const r = await api('/api/colores/quitar', {raiz: raizActual()});
|
||
aviso(`${r.quitadas} carpetas vueltas a su icono normal.`, 'ok');
|
||
await cargarMapaEst(MAPAE.tipo);
|
||
});
|
||
|
||
// ---------------- soltar carpeta para añadir ----------------
|
||
const zona = $('#zonaSoltar');
|
||
['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); zona.classList.add('sobre'); $('#vacioCaja').classList.add('sobre'); }));
|
||
['dragleave','drop'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); if (ev === 'drop' || e.target === document.body) { zona.classList.remove('sobre'); $('#vacioCaja').classList.remove('sobre'); } }));
|
||
document.addEventListener('drop', seguro(async e => {
|
||
e.preventDefault();
|
||
let nombre = '';
|
||
const conRuta = Array.from(e.dataTransfer.files || []).find(f => f.pywebviewFullPath);
|
||
if (conRuta) { const ruta = conRuta.pywebviewFullPath; const esArchivo = conRuta.type || /\.[a-z0-9]{1,5}$/i.test(conRuta.name); return proponerRaiz(esArchivo ? ruta.replace(/[\\/][^\\/]*$/, '') : ruta); }
|
||
const items = e.dataTransfer.items ? Array.from(e.dataTransfer.items) : [];
|
||
for (const it of items) { const ent = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (ent && ent.isDirectory) { nombre = ent.name; break; } }
|
||
if (!nombre) { const f = e.dataTransfer.files[0]; if (f && f.webkitRelativePath) nombre = f.webkitRelativePath.split('/')[0]; else if (f) nombre = f.name.replace(/\.[^.]+$/, ''); }
|
||
const texto = e.dataTransfer.getData('text');
|
||
if (!nombre && texto && /^[A-Za-z]:\\/.test(texto.trim())) return proponerRaiz(texto.trim());
|
||
if (!nombre) return aviso('Suelta una carpeta (no un archivo).', 'bad');
|
||
// dentro de la ventana de OrganiLAB (WebView2) la ruta completa llega por el puente: no hay que buscar
|
||
if (window.pywebview || (window.chrome && window.chrome.webview)) {
|
||
modal('Leyendo la carpeta…', `<p><span class="spin"></span> «${esc(nombre)}»</p>`, null);
|
||
const s = await api('/api/soltado?nombre=' + encodeURIComponent(nombre));
|
||
if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\\/][^\\/]*$/, '') : s.ruta);
|
||
}
|
||
modal('Buscando la carpeta…', `<p><span class="spin"></span> El navegador solo entrega el nombre («${esc(nombre)}»). Buscando en los discos…</p>`, null);
|
||
const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre));
|
||
if (!d.coincidencias.length) return modal('No la encontré', `<p>No hay ninguna carpeta llamada <b>${esc(nombre)}</b> en los tres primeros niveles de los discos. Pega la ruta completa:</p><input type="text" id="rutaPegada" style="width:100%" placeholder="Z:\\Clientes\\${esc(nombre)}">`, () => proponerRaiz($('#rutaPegada').value.trim()));
|
||
if (d.coincidencias.length === 1) return proponerRaiz(d.coincidencias[0]);
|
||
modal('¿Cuál es?', `<p>Hay varias carpetas con ese nombre:</p>${d.coincidencias.map((c,i) => `<label style="display:block;margin:4px 0"><input type="radio" name="cand" value="${esc(c)}" ${i===0?'checked':''}> <span class="path">${esc(c)}</span></label>`).join('')}`, () => proponerRaiz(document.querySelector('input[name=cand]:checked').value));
|
||
}));
|
||
async function elegirCarpeta(btn){
|
||
await accion(btn, async () => {
|
||
const d = await api('/api/elegir-carpeta', {});
|
||
if (d.ruta) await proponerRaiz(d.ruta);
|
||
});
|
||
}
|
||
zona.onclick = () => { modal('Añadir carpeta de cliente', '<p><button class="p" type="button" id="btnElegir3">Elegir carpeta…</button> <span class="mut">abre el selector de Windows</span></p><p style="margin-top:12px">O pega la ruta completa (en el Explorador: Mayús + clic derecho → «Copiar como ruta de acceso»):</p><input type="text" id="rutaPegada" style="width:100%" placeholder="Z:\\Dermatologas\\Nombre del cliente">', () => { const v = $('#rutaPegada').value.trim().replace(/^"|"$/g, ''); if (v) proponerRaiz(v); }); $('#btnElegir3').onclick = () => { $('#modal').classList.remove('on'); elegirCarpeta($('#btnElegir')); }; };
|
||
$('#btnElegir').onclick = () => elegirCarpeta($('#btnElegir'));
|
||
$('#btnElegir2').onclick = e => { e.stopPropagation(); elegirCarpeta($('#btnElegir2')); };
|
||
zona.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); zona.onclick(); } };
|
||
$('#vacioCaja').onclick = () => zona.onclick(); $('#vacioCaja').onkeydown = zona.onkeydown;
|
||
const MESES_UI = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||
const EJEMPLO_CUBETAS = [['GRABACION', 'lo que sale de la cámara'], ['PIEZA', 'cada pieza, con su edición'],
|
||
['EXPORTADOS', 'lo que ya se entregó'], ['MUSICA', 'música y sonidos de siempre'],
|
||
['BASURA', 'lo que sobra, para que lo borres tú']];
|
||
function ejemploTipo(ruta, tipo){
|
||
// se arma con la plantilla de verdad, así que el ejemplo es exactamente lo que la app va a hacer. Va como
|
||
// árbol: las carpetas que comparten camino (el año, el mes) salen una sola vez, como en el Explorador.
|
||
const pl = (ESTADO.plantillas || {})[tipo] || {}, h = new Date();
|
||
const val = {anio: h.getFullYear(), mes: `${String(h.getMonth() + 1).padStart(2, '0')}- ${MESES_UI[h.getMonth()]}`,
|
||
nombre: tipo === 'pauta' ? 'Nombre de la campaña' : 'Nombre de la pieza', sub: '', fuente: '', rel: ''};
|
||
const raiz = {hijos: new Map()};
|
||
for (const [cub, que] of EJEMPLO_CUBETAS){
|
||
const dest = (pl[cub] || '').replace(/\{(\w+)\}/g, (_, k) => val[k] !== undefined ? String(val[k]) : '')
|
||
.replace(/\/+$/, '').replace(/\/+/g, '\\');
|
||
if (!dest) continue;
|
||
let n = raiz;
|
||
for (const seg of dest.split('\\')) { if (!n.hijos.has(seg)) n.hijos.set(seg, {hijos: new Map(), que: ''}); n = n.hijos.get(seg); }
|
||
n.que = n.que || que;
|
||
}
|
||
const lineas = [`<div class="ln"><b>${esc((ruta || '').replace(/[\\/]+$/, ''))}\\</b></div>`];
|
||
const pinta = (nodo, sangria) => {
|
||
const hs = [...nodo.hijos.entries()];
|
||
hs.forEach(([nombre, h], i) => {
|
||
const ultimo = i === hs.length - 1;
|
||
lineas.push(`<div class="ln"><b>${esc(sangria)}${ultimo ? '└─ ' : '├─ '}${esc(nombre)}\\</b>${h.que ? `<span>${esc(h.que)}</span>` : ''}</div>`);
|
||
pinta(h, sangria + (ultimo ? ' ' : '│ '));
|
||
});
|
||
};
|
||
pinta(raiz, '');
|
||
return lineas.join('');
|
||
}
|
||
function tarjetasTipo(elegido){
|
||
// las cuatro clases a la vista, cada una con los clientes que ya van así: se elige por parecido
|
||
const quien = t => Object.entries(ESTADO.config.clientes || {}).filter(([, c]) => (c.tipo || 'contenido') === t).map(([n]) => n);
|
||
return Object.entries(ESTADO.tipos).map(([k, v]) => {
|
||
const [titulo, orden] = v.split(' · '), q = quien(k);
|
||
return `<label class="tipo"><input type="radio" name="nuevoTipo" value="${k}" ${k === elegido ? 'checked' : ''}><b>${esc(titulo)}</b>` +
|
||
`<span>${esc(orden || '')}</span>${q.length ? `<em title="${esc(q.join(', '))}">como ${esc(q.slice(0, 3).join(', '))}${q.length > 3 ? ` y ${q.length - 3} más` : ''}</em>` : ''}</label>`;
|
||
}).join('');
|
||
}
|
||
function proponerRaiz(ruta){
|
||
if (!ruta) return;
|
||
const clientes = Object.keys(ESTADO.config.clientes);
|
||
const nombre = ruta.replace(/[\\/]+$/, '').split(/[\\/]/).pop();
|
||
const sugerido = clientes.find(c => (ESTADO.config.clientes[c].alias || []).concat(c).some(a => a.toLowerCase() === nombre.toLowerCase())) || nombre;
|
||
const tipoDe = c => ((ESTADO.config.clientes[c] || {}).tipo) || 'contenido';
|
||
modal('Añadir carpeta de cliente', `<p class="path">${esc(ruta)}</p>
|
||
<div class="preg"><label for="nuevoCliente">¿De quién es este material?</label>
|
||
<input type="text" id="nuevoCliente" value="${esc(sugerido)}" list="lcli2" style="width:100%">
|
||
<datalist id="lcli2">${clientes.map(c => `<option value="${esc(c)}">`).join('')}</datalist>
|
||
<span>Con ese nombre reúne todas sus carpetas, aunque en cada disco estén escritas distinto.</span></div>
|
||
<div class="preg"><label>¿Qué clase de trabajo le haces?</label>
|
||
<div class="tipos" id="nuevoTipo">${tarjetasTipo(tipoDe(sugerido))}</div>
|
||
<span>Es lo único que decide en qué carpetas va a guardar su material. Se cambia luego en Ajustes.</span></div>
|
||
<p style="margin:14px 0 0"><b>Así va a quedar ordenada:</b></p>
|
||
<div class="arbol" id="ejemploTipo"></div>
|
||
<p class="mut" style="margin-top:12px">Al confirmar solo la lee y te arma una lista de lo que haría. <b>No mueve ni un archivo</b> hasta que tú lo apliques.</p>`, seguro(async () => {
|
||
const cliente = $('#nuevoCliente').value.trim() || nombre, tipo = tipoElegido();
|
||
const raices = ESTADO.config.raices.filter(r => r.ruta.toLowerCase() !== ruta.toLowerCase()); raices.push({ruta, cliente, tipo});
|
||
await api('/api/raices', {raices});
|
||
if (!ESTADO.config.clientes[cliente]) { ESTADO.config.clientes[cliente] = {tipo, alias: [nombre]}; await api('/api/clientes', {clientes: ESTADO.config.clientes}); }
|
||
await cargarEstado(); const nueva = ESTADO.raices.find(r => r.ruta === ruta); $('#raiz').value = nueva ? nueva.clave : ruta; $('#raiz').onchange();
|
||
await api('/api/escanear', {raiz: raizActual()}); await cargarEstado(); irA('carpeta');
|
||
aviso('Carpeta añadida. Escaneando…', 'ok');
|
||
}));
|
||
const inp = $('#nuevoCliente');
|
||
const tipoElegido = () => { const r = document.querySelector('#nuevoTipo input:checked'); return r ? r.value : 'contenido'; };
|
||
const pinta = () => $('#ejemploTipo').innerHTML = ejemploTipo(ruta, tipoElegido());
|
||
$$('#nuevoTipo input').forEach(r => r.onchange = pinta);
|
||
// si escribe el nombre de un cliente que ya existe, manda el tipo que ese cliente ya tiene: dos
|
||
// carpetas del mismo cliente ordenadas con plantillas distintas serían un lío
|
||
inp.oninput = () => { const c = ESTADO.config.clientes[inp.value.trim()]; const r = c && c.tipo && document.querySelector(`#nuevoTipo input[value="${c.tipo}"]`); if (r && !r.checked) { r.checked = true; pinta(); } };
|
||
pinta();
|
||
}
|
||
|
||
// ---------------- asistente ----------------
|
||
async function esperarEscaneo(raiz){
|
||
for (;;) { await cargarEstado(); const r = ESTADO.raices.find(x => x.clave === raiz); const p = r && r.progreso; if (!p || p.estado !== 'corriendo') return p; await new Promise(r => setTimeout(r, 700)); }
|
||
}
|
||
const asistente = seguro(async (raiz, escanearAntes) => {
|
||
raiz = raiz || raizActual(); if (!raiz) return;
|
||
const r = ESTADO.raices.find(x => x.clave === raiz);
|
||
if (escanearAntes || !(r && r.escaneo)) { await api('/api/escanear', {raiz}); const p = await esperarEscaneo(raiz); if (p && p.estado === 'error') throw new ApiError('No se pudo escanear: ' + p.error); }
|
||
modal('Asistente', '<span class="spin"></span> pensando el diagnóstico y el plan…');
|
||
const d = await api('/api/asistente/pasos?raiz=' + encodeURIComponent(raiz));
|
||
const dg = d.diagnostico;
|
||
let html = `<p class="mut">${num(dg.n)} archivos · ${gb(dg.bytes)}</p><div class="asis-diag">` +
|
||
dg.grupos.map(g => `<b>${gb(g.bytes)}</b><span class="mut">${g.pct} %</span><span title="${esc(g.que_es)}">${esc(g.titulo)} <span class="mut">(${num(g.n)}${g.desde ? ' · ' + esc(g.desde) + (g.hasta && g.hasta !== g.desde ? ' → ' + esc(g.hasta) : '') : ''})</span></span>`).join('') + '</div>' +
|
||
dg.frases.map(f => `<div class="asis-frase">• ${esc(f)}</div>`).join('') +
|
||
`<p style="margin-top:10px"><b>¿Qué hacemos con cada cosa?</b> <span class="mut">${d.movimientos} movimientos posibles</span></p>` +
|
||
d.pasos.map(p => `<div class="asis-paso"><h4>${esc(p.titulo)}</h4><div class="mut" style="font-size:12px">${esc(p.resumen)}</div>` +
|
||
p.opciones.map(([k, t]) => `<label><input type="radio" name="asis_${p.clave}" value="${k}" ${k === p.defecto ? 'checked' : ''}> ${esc(t)}</label>`).join('') + '</div>').join('') +
|
||
'<p class="mut" style="font-size:12px">Nada se borra: lo sobrante va a _Para borrar dentro de la misma carpeta y cada cambio se deshace desde Historial.</p>';
|
||
modal('Asistente · ' + raiz.split(/[\\/]/).pop(), html, seguro(async () => {
|
||
const decisiones = {}; d.pasos.forEach(p => { const el = document.querySelector(`input[name=asis_${p.clave}]:checked`); decisiones[p.clave] = el ? el.value : p.defecto; });
|
||
const r = await api('/api/asistente', {raiz, decisiones}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar');
|
||
aviso('El asistente está trabajando. Sigue el avance en la barra de abajo.', 'ok'); await cargarEstado(); cargarVista();
|
||
}));
|
||
$('#modalSi').textContent = 'Empezar';
|
||
});
|
||
const NOMBRE_PASO = {plan: 'pensando el plan', ordenar: 'ordenando carpetas y archivos', stock: 'apartando stock viejo', duplicados: 'juntando repetidos', analizar: 'leyendo los clips', convertir: 'convirtiendo a 10 bits', respaldo: 'copiando el respaldo', fin: 'listo'};
|
||
|
||
// ---------------- modal ----------------
|
||
function modal(titulo, html, onSi){ $('#modal').classList.remove('ancho'); $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalSi').disabled = false; $('#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 y vuelve a abrirse solo.</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 solo con la versión nueva.</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>
|