1.6.3: botón Elegir carpeta con el selector de Windows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-15 19:52:58 -05:00
padre 8b482af652
commit b1f65f94c2
Se han modificado 4 ficheros con 152 adiciones y 35 borrados

Ver fichero

@@ -275,6 +275,10 @@ en la barra de tareas para siempre y bloqueaba desconectar la carpeta. Pruebas a
temporal): `py -3.12 probar.py`. Rendimiento medido sobre el inventario real (54 mil archivos): las consultas
con LIKE tardan menos de 10 ms; no hacía falta optimizar.
1.6.3: botón «Elegir carpeta…» (barra lateral, pantalla vacía y diálogo de añadir) que abre el selector de
carpetas de Windows, sin depender de soltar la carpeta ni de pegar la ruta. `POST /api/elegir-carpeta` lanza el
diálogo en un PowerShell aparte para no bloquear el servidor y devuelve la ruta (o nulo si se cancela).
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -1,19 +1,19 @@
{
"nombre": "OrganiLAB",
"version": "1.6.2",
"builtAt": "2026-09-15T19:46:00.000Z",
"archivo": "OrganizadorLabstream-portatil-1.6.2.zip",
"size": 198625969,
"sha256": "48ef016966c45d53f6d1d934a6e7bf735c0b20e46413c487ffdd2b4b04b3ef89",
"version": "1.6.3",
"builtAt": "2026-09-16T00:50:31.000Z",
"archivo": "OrganizadorLabstream-portatil-1.6.3.zip",
"size": 198631521,
"sha256": "f84af3a46e4284660acac3dd0fd7fdfee9437c680a899dbfcc3cbf85660a9962",
"app": {
"archivo": "OrganizadorLabstream-app-1.6.2.zip",
"size": 14806232,
"sha256": "f45a61a47ffd678f76e6e184fefff6f2cceb860a6b82f8d305b823cebbf2b853",
"url": "/api/herramientas/OrganizadorLabstream-app-1.6.2.zip"
"archivo": "OrganizadorLabstream-app-1.6.3.zip",
"size": 14811784,
"sha256": "8ec8fcacbbd865c4aca52d233667d4281f5510e3bf14044e8efd5173c2beeeb9",
"url": "/api/herramientas/OrganizadorLabstream-app-1.6.3.zip"
},
"notes": "",
"firma": {
"alg": "rsa2048-pkcs1v15-sha256",
"valor": "013a3ac0886f655e22e2a30913be8a54880e43ec5663dcda8537a220a014438998533b1d440b84e33e7393e8d544089c215d7af3acb27390471ffa728da1d4f452cffad7534c51041f40695152431086b165348219fba51de93b1ae0403a3b748fc1e0022a707797868825d934d21d676debc6cfa8b54d6e7f4b0bd5c83b6ed6a9fe1f60bcee5cc9e5aa06710e120c2bbcdbcd59356e9931aed4c3206815b8c99746940be562a196144c9e22c4e0aa17ea43852c79a9b6e23076ff18c89c76971946aed10e05ca29aaedc95f3e0d39ffa71f020125d7bf272d9a5f9ef8b5f6b6e5cbb4930e12b4c598b6343bd779c1d0ead18aad70eaa442badc303ad22bbca3"
"valor": "58afe4902d9cc98d02ffb98a3376de4dd9da56504c24a88ef1ff3175f8832cac22a20ae9439aca68cba6bbb2126441770b3abe190f5d99d6b313830dd4cc7c4000eede641e97a169f7cebdf541abd618a6f27e032d0c1931aa567849f91a63489b67f3d062a4ea3fd6478d5479a6da9c0cfd56242afbf133ecc2558c95448e443054fd8879a03ccf54c0e0e23057f9923a7ef0fac270ea8f512aab2caf1b1c506f9e8ea749604171a2364d9054901582b455681947d9a26b55459b5b880023d41d532730324daeec88175990a0883c96ad670f79925e35335c68658ccfc70326a7adead5b95c6f3b6d5f309dfc5214734f039397ff9a05b2348643bce27c54fa"
}
}

Ver fichero

@@ -33,7 +33,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "1.6.2"
VERSION = "1.6.3"
CONGELADO = getattr(sys, "frozen", False)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
@@ -1560,9 +1560,34 @@ def bucle_cola():
COLA["hilo"] = None
def recuperar_huerfanas():
"""Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de
una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso
Convertir decía «nada» (visto el 2026-09-15: 5 clips de V:\\Galeano al 18-89 % desde el día 10).
Vuelven a «pendiente» con el motivo en el log; Reanudar los convierte desde cero."""
if COLA["hilo"] and COLA["hilo"].is_alive():
return 0
con = db()
with con:
filas = con.execute("SELECT id, pct FROM trabajos WHERE estado = 'corriendo'").fetchall()
for f in filas:
con.execute("UPDATE trabajos SET estado = 'pendiente', pct = 0, log = ? WHERE id = ?",
(f"se interrumpió al cerrar OrganiLAB (iba al {round(f['pct'] or 0)} %); pulsa Reanudar para convertirlo de nuevo", f["id"]))
con.close()
return len(filas)
class ErrorCola(ValueError):
"""Un ValueError que además lleva datos para la interfaz (qué trabajos estorban), no solo un texto."""
def __init__(self, mensaje, datos):
super().__init__(mensaje)
self.datos = datos
def iniciar_cola():
if COLA["hilo"] and COLA["hilo"].is_alive():
return False
recuperar_huerfanas()
COLA["activa"] = True
COLA["cancelar"] = False
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
@@ -2032,8 +2057,31 @@ def _clave(ruta):
return os.path.normpath(ruta)
def quitar_raiz(ruta, borrar_datos=False):
"""Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear)."""
_ELIGIENDO = threading.Lock()
def elegir_carpeta():
"""Abre el selector de carpetas de Windows (en un PowerShell aparte, para no bloquear el servidor) y devuelve la ruta o None."""
if not _ELIGIENDO.acquire(blocking=False):
raise ValueError("ya hay un selector de carpetas abierto")
try:
ps = ("Add-Type -AssemblyName System.Windows.Forms; "
"$d = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$d.Description = 'Elige la carpeta del cliente'; $d.ShowNewFolderButton = $false; "
"$f = New-Object System.Windows.Forms.Form; $f.TopMost = $true; "
"if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }")
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
out = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-STA", "-Command", ps], capture_output=True, timeout=600, creationflags=flags)
ruta = out.stdout.decode("utf-8", "replace").strip() or out.stdout.decode("mbcs", "replace").strip()
return os.path.normpath(ruta) if ruta and os.path.isdir(ruta) else None
finally:
_ELIGIENDO.release()
def quitar_raiz(ruta, borrar_datos=False, descartar_cola=False):
"""Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear).
Con descartar_cola se quitan de la cola sus conversiones EN ESPERA (no toca archivos); las que se están
convirtiendo ahora mismo nunca se descartan desde aquí: hay que detener la cola primero."""
k = _clave(ruta)
r = raiz_cfg(ruta)
if not r:
@@ -2042,11 +2090,27 @@ def quitar_raiz(ruta, borrar_datos=False):
raise ValueError("la carpeta se está escaneando o analizando; espera a que termine")
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"] and (APLICANDO.get("raiz") or "").lower() == k.lower():
raise ValueError("se está aplicando el plan de esa carpeta; espera a que termine")
recuperar_huerfanas()
con = db()
activos = con.execute("SELECT COUNT(*) n FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo')", (k.lower(),)).fetchone()["n"]
activos = [dict(x) for x in con.execute("SELECT id, ruta, estado, pct FROM trabajos WHERE lower(raiz) = ? AND estado IN ('pendiente','corriendo') ORDER BY id", (k.lower(),)).fetchall()]
if activos:
en_curso = [x for x in activos if x["estado"] == "corriendo"]
if en_curso or not descartar_cola:
con.close()
raise ValueError(f"hay {activos} conversiones pendientes o en curso de esa carpeta; detén la cola o límpiala primero")
n = len(activos)
por_estado = {"corriendo": len(en_curso), "pendiente": n - len(en_curso)}
que = ", ".join(f"{v} {'convirtiéndose ahora' if e == 'corriendo' else 'en espera'}" for e, v in por_estado.items() if v)
# El texto dice QUÉ hay y QUÉ hacer; los datos (archivos, estados) van aparte para que la
# interfaz los enseñe y ofrezca descartar. Antes solo decía «detén la cola o límpiala».
if en_curso:
msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): detén la cola en Convertir a 10 bits y vuelve a intentarlo"
else:
msg = f"hay {n} conversiones de esa carpeta sin terminar ({que}): termínalas con Reanudar en Convertir a 10 bits, o descártalas al desconectar"
raise ErrorCola(msg, {"cola": {"n": n, "por_estado": por_estado, "en_curso": bool(en_curso),
"archivos": [os.path.basename(x["ruta"]) for x in activos[:6]]}})
with con:
con.execute("DELETE FROM trabajos WHERE lower(raiz) = ? AND estado = 'pendiente'", (k.lower(),))
bitacora({"op": "cola_descartada", "raiz": k, "n": len(activos), "archivos": [x["ruta"] for x in activos]})
CONFIG["raices"] = [x for x in CONFIG["raices"] if _clave(x["ruta"]).lower() != k.lower()]
guardar_json(CONFIG_PATH, CONFIG)
PLANES.pop(k, None); PROGRESO.pop(k, None); ANALISIS.pop(k, None)
@@ -2283,9 +2347,16 @@ class Manejador(BaseHTTPRequestHandler):
try:
if ruta == "/api/escanear":
return self._json({"lanzado": lanzar_escaneo(d["raiz"])})
if ruta == "/api/elegir-carpeta":
try:
return self._json({"ruta": elegir_carpeta()})
except ValueError as e:
return self._json({"error": str(e)}, 409)
if ruta == "/api/raiz/quitar":
try:
return self._json(quitar_raiz(d["raiz"], bool(d.get("datos"))))
return self._json(quitar_raiz(d["raiz"], bool(d.get("datos")), bool(d.get("descartar_cola"))))
except ErrorCola as e:
return self._json({"error": str(e), **e.datos}, 409)
except ValueError as e:
return self._json({"error": str(e)}, 409)
if ruta == "/api/raices":
@@ -2698,6 +2769,9 @@ def main():
h = herramientas()
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"])
print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)")
huerfanas = recuperar_huerfanas()
if huerfanas:
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
try:
srv = ThreadingHTTPServer(("127.0.0.1", PUERTO), Manejador)
except OSError:

Ver fichero

@@ -249,6 +249,7 @@
<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">
@@ -269,7 +270,7 @@
<div class="vacio-caja" id="vacioCaja" role="button" tabindex="0">
<h2>Suelta aquí la carpeta de un cliente</h2>
<p>OrganiLAB la escanea, te enseña qué pesa y te lleva paso a paso. No mueve nada sin que lo confirmes, y nunca borra.</p>
<div class="o">o <u>pega la ruta de la carpeta</u></div>
<div class="o"><button class="p" id="btnElegir2" type="button">Elegir carpeta…</button> &nbsp; o <u>pega la ruta</u></div>
</div>
<div class="vacio-pasos">
<div><b>1 · Escanear</b>Lee nombres, tamaños y fechas. Solo lectura.</div>
@@ -372,6 +373,7 @@
<button class="lnk" id="btnReintentar" style="display:none">Reintentar los que fallaron</button>
<button class="lnk" id="btnVerificar" style="display:none">Volver a verificar</button>
<button class="lnk" id="btnQuitar" style="display:none">Limpiar terminados</button>
<button class="lnk" id="btnQuitarEspera" style="display:none">Quitar los que esperan</button>
</div>
<div id="anaEstado" class="mut" style="margin:-6px 0 10px"></div>
<div class="box" id="convCaja"><div id="convLista"><span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span></div></div>
@@ -452,11 +454,18 @@ async function api(u, body){
try { r = await fetch(u, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : undefined); }
catch (e) { throw new ApiError('No hay conexión con OrganiLAB. ¿Se cerró el programa?'); }
let j = null; try { j = await r.json(); } catch (e) { throw new ApiError(`Respuesta rara del programa (${r.status})`); }
if (!r.ok || (j && j.error)) throw new ApiError((j && j.error) || `Error ${r.status}`);
// 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;
}
function aviso(texto, tipo, enlace){
// 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();
@@ -466,11 +475,13 @@ async function accion(btn, fn){
if (btn.disabled) return;
const antes = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="spin"></span>' + antes;
try { await fn(); }
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad'); }
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); }
finally { btn.disabled = false; btn.innerHTML = antes; }
}
// qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí
const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || ''));
function bloquear(id, motivo){ const b = $('#' + id); if (!b) return; b.disabled = !!motivo; const p = $('#' + id + 'PorQue'); if (p) p.textContent = motivo && motivo !== 'x' ? motivo : ''; }
const seguro = fn => async (...a) => { try { await fn(...a); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad'); } };
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; }
@@ -607,14 +618,21 @@ function desconectar(clave){
const ocupada = tareasDe(ESTADO).some(t => (t.raiz || '').toLowerCase() === clave.toLowerCase());
if (ocupada) { aviso('Esa carpeta tiene una tarea en curso. Espera a que termine.', 'bad'); return; }
modal('Desconectar «' + r.cliente + '»', `<p>La carpeta <span class="path">${esc(r.ruta)}</span> desaparece de la lista de la izquierda. <b>No se borra ni se mueve nada del disco</b>; puedes volver a soltarla cuando quieras.</p><label style="display:flex;gap:8px;align-items:flex-start;margin-top:10px;font-size:13px"><input type="checkbox" id="mDatos" style="margin-top:3px"><span>Olvidar también el inventario escaneado${r.escaneo ? ` (${num(r.escaneo.n)} archivos · ${gb(r.escaneo.bytes)})` : ''}. Si la vuelves a conectar habrá que escanearla de nuevo.</span></label>`, async () => {
const olvidar = $('#mDatos').checked;
const listo = async (n) => { aviso(n ? `Carpeta desconectada; ${n} conversión${n > 1 ? 'es' : ''} descartada${n > 1 ? 's' : ''}.` : 'Carpeta desconectada.', 'ok'); await cargarEstado(); const s = $('#raiz'); if (s.value === clave) { s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); } else { pintarIndice(); cargarVista(); } };
try {
const era = raizActual() === clave;
await api('/api/raiz/quitar', {raiz: clave, datos: $('#mDatos').checked});
aviso('Carpeta desconectada.', 'ok');
await cargarEstado();
if (era) { const s = $('#raiz'); s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); }
else pintarIndice();
} catch (e) { aviso(e.message, 'bad'); }
await api('/api/raiz/quitar', {raiz: clave, datos: olvidar});
await listo(0);
} catch (e) {
const c = e.datos && e.datos.cola;
if (!c) { aviso(e.message, 'bad', null, detalleDe(e)); return; }
// Conversiones sin terminar: se enseñan (cuáles, en qué estado) y, si ninguna está convirtiéndose ahora,
// se ofrece descartarlas y desconectar de una vez. Antes solo salía un globo sin decir cuáles.
apuntarAviso(e.message, c.archivos.join('\n'));
modal('No se pudo desconectar «' + r.cliente + '»', `<p>${esc(e.message)}.</p><p><b>${c.n}</b> conversión${c.n > 1 ? 'es' : ''} sin terminar${c.por_estado.corriendo ? ` (${c.por_estado.corriendo} convirtiéndose ahora)` : ''}:</p><pre>${esc(c.archivos.join('\n'))}${c.n > c.archivos.length ? `\n… y ${c.n - c.archivos.length} más` : ''}</pre><p class="mut" style="font-size:12.5px">${c.en_curso ? 'Detén la cola en <b>Convertir a 10 bits</b> y vuelve a intentarlo.' : 'Descartarlas las quita de la cola sin tocar ningún archivo: los originales siguen donde están, y si vuelves a conectar la carpeta puedes encolarlas otra vez.'}</p>`,
c.en_curso ? null : async () => { try { await api('/api/raiz/quitar', {raiz: clave, datos: olvidar, descartar_cola: true}); await listo(c.n); } catch (e2) { aviso(e2.message, 'bad', null, detalleDe(e2)); } });
if (!c.en_curso) $('#modalSi').textContent = 'Descartar y desconectar';
}
});
}
$('#btnDesconectar').onclick = () => desconectar(raizActual());
@@ -667,13 +685,18 @@ function pasosDe(C, r){
const cv = C.convertir || {}; const tr = cv.trabajos || {}; const n = k => (tr[k] || {}).n || 0;
const activos = n('pendiente') + n('corriendo'), listos = n('listo'), verif = n('verificado'), reemp = n('reemplazado');
const anaRun = cv.analisis && cv.analisis.estado === 'corriendo';
P.push(!esc ? {e:'bloq', t:'Convertir', d:'Después de escanear'} : !cv.camara_bytes ? {e:'hecho', t:'Convertir', d:'No hay material de cámara', corto: 'nada'}
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 && !activos ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya en 10 bits`, corto: `${reemp} hechos`}
: activos ? {e:'listo', t:'Convertir', d:`${activos} en cola, parada`, corto: 'parada'}
: reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya en 10 bits`, corto: `${reemp} hechos`}
: {e:'pend', t:'Convertir', d:`${gb(cv.camara_bytes)} de cámara${cv.raw_bytes ? ' · ' + gb(cv.raw_bytes) + ' BRAW' : ''}`, corto: gb(cv.camara_bytes)});
const rp = C.respaldo || {};
P.push(!esc ? {e:'bloq', t:'Respaldar', d:'Después de escanear'} : rp.estado === 'corriendo' ? {e:'curso', t:'Respaldar', d:'Copiando…', corto: 'copiando'}
@@ -879,6 +902,7 @@ async function cargarCola(){
const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}` : 'sin actividad';
const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Reanudar';
$('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none';
$('#btnQuitarEspera').style.display = (!d.cola.activa && pend) ? '' : 'none';
bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar';
const validos = CAND.filter(c => !c.excluido), ahora = validos.reduce((s,c)=>s+c.bytes,0), despues = validos.reduce((s,c)=>s+c.estimado,0);
const hechosB = ts.filter(t => t.bytes_out).reduce((s,t) => s + (t.bytes_in - t.bytes_out), 0);
@@ -891,7 +915,7 @@ async function cargarCola(){
}
if (ts.length) {
html += `<div class="cabsec">Conversiones<span class="sep"></span>${ts.length}</div>`;
html += ts.slice(0, 120).map(t => `<div class="cfila"><span></span><span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}</span><span class="nom" title="${esc(t.ruta)}"><span class="clip">${esc((t.ruta || '').split(/[\\/]/).pop())} <span class="mut" style="font-weight:500;font-size:11px">· ${t.motor === 'resolve' ? 'Resolve' : 'ffmpeg · GPU'}</span></span><span class="sub2">${t.estado === 'error' ? `<span class="bad">${esc((t.log || '').slice(-110))}</span>` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}</span>${t.estado === 'corriendo' ? `<div class="bar"><i style="width:${Math.round(t.pct || 0)}%"></i></div>` : ''}</span><span class="tam">${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}</span><span class="ah">${t.bytes_out ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) + '%' : ''}</span></div>`).join('') + (ts.length > 120 ? `<div class="mut" style="padding:8px 0">… y ${ts.length - 120} más</div>` : '');
html += ts.slice(0, 120).map(t => `<div class="cfila"><span></span><span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}</span><span class="nom" title="${esc(t.ruta)}"><span class="clip">${esc((t.ruta || '').split(/[\\/]/).pop())} <span class="mut" style="font-weight:500;font-size:11px">· ${t.motor === 'resolve' ? 'Resolve' : 'ffmpeg · GPU'}</span></span><span class="sub2">${t.estado === 'error' ? `<span class="bad">${esc((t.log || '').slice(-110))}</span>` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `<span class="mut">${esc(t.log)}</span>` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}</span>${t.estado === 'corriendo' ? `<div class="bar"><i style="width:${Math.round(t.pct || 0)}%"></i></div>` : ''}</span><span class="tam">${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}</span><span class="ah">${t.bytes_out ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) + '%' : ''}</span></div>`).join('') + (ts.length > 120 ? `<div class="mut" style="padding:8px 0">… y ${ts.length - 120} más</div>` : '');
}
$('#convLista').innerHTML = html || '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>';
$$('#convLista input[data-i]').forEach(i => i.onchange = () => { CAND[+i.dataset.i].sel = i.checked; resumenCand(); });
@@ -903,6 +927,9 @@ $('#btnVerificar').onclick = () => accion($('#btnVerificar'), async () => { cons
$('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `<p><b>${ids.length}</b> originales se apartan a <span class="path">_Para borrar/originales</span> dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.</p><p>Cuando estés seguro, una persona vacía _Para borrar a mano.</p>`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; });
$('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); });
$('#btnQuitar').onclick = () => accion($('#btnQuitar'), async () => { const ids = await idsPorEstado('error', 'cancelado', 'reemplazado'); await api('/api/optimizar/control', {accion: 'quitar', ids}); cargarCola(); });
// Con la cola parada, las que esperan también se pueden quitar (antes no había forma desde la interfaz, y
// bloqueaban Desconectar). No toca archivos: solo las saca de la cola.
$('#btnQuitarEspera').onclick = () => accion($('#btnQuitarEspera'), async () => { const ids = await idsPorEstado('pendiente'); if (!ids.length) throw new ApiError('No hay conversiones en espera de esta carpeta.'); modal('Quitar las conversiones en espera', `<p><b>${ids.length}</b> conversión${ids.length > 1 ? 'es' : ''} en espera se quita${ids.length > 1 ? 'n' : ''} de la cola. No se toca ningún archivo: los originales siguen donde están y puedes volver a encolarlas desde «Buscar clips».</p>`, () => accion($('#btnQuitarEspera'), async () => { await api('/api/optimizar/control', {accion: 'quitar', ids}); await cargarEstado(); cargarCola(); })); $('#modalSi').textContent = 'Quitar'; });
// ---------------- respaldar ----------------
function pintarRespaldo(){
@@ -944,8 +971,12 @@ async function cargarBit(){
const dias = {}; lotes.forEach(l => { const k = l.ts ? new Date(l.ts * 1000).toDateString() : '?'; (dias[k] = dias[k] || []).push(l); });
const hoy = new Date().toDateString(), ayer = new Date(Date.now() - 86400000).toDateString();
const titulo = k => k === hoy ? 'Hoy' : k === ayer ? 'Ayer' : new Date(k).toLocaleDateString('es-CO', {weekday: 'long', day: 'numeric', month: 'long'});
$('#historial').innerHTML = Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho || l.lote === 'optimizar' ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>';
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho || l.lote === 'optimizar' ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
}
$('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad'));
@@ -1000,7 +1031,15 @@ document.addEventListener('drop', seguro(async e => {
if (d.coincidencias.length === 1) return proponerRaiz(d.coincidencias[0]);
modal('¿Cuál es?', `<p>Hay varias carpetas con ese nombre:</p>${d.coincidencias.map((c,i) => `<label style="display:block;margin:4px 0"><input type="radio" name="cand" value="${esc(c)}" ${i===0?'checked':''}> <span class="path">${esc(c)}</span></label>`).join('')}`, () => proponerRaiz(document.querySelector('input[name=cand]:checked').value));
}));
zona.onclick = () => modal('Añadir carpeta de cliente', '<p>Pega la ruta completa de la carpeta (en el Explorador: Mayús + clic derecho → «Copiar como ruta de acceso»).</p><input type="text" id="rutaPegada" style="width:100%" placeholder="Z:\\Dermatologas\\Nombre del cliente">', () => proponerRaiz($('#rutaPegada').value.trim().replace(/^"|"$/g, '')));
async function elegirCarpeta(btn){
await accion(btn, async () => {
const d = await api('/api/elegir-carpeta', {});
if (d.ruta) await proponerRaiz(d.ruta);
});
}
zona.onclick = () => { modal('Añadir carpeta de cliente', '<p><button class="p" type="button" id="btnElegir3">Elegir carpeta…</button> <span class="mut">abre el selector de Windows</span></p><p style="margin-top:12px">O pega la ruta completa (en el Explorador: Mayús + clic derecho → «Copiar como ruta de acceso»):</p><input type="text" id="rutaPegada" style="width:100%" placeholder="Z:\\Dermatologas\\Nombre del cliente">', () => { const v = $('#rutaPegada').value.trim().replace(/^"|"$/g, ''); if (v) proponerRaiz(v); }); $('#btnElegir3').onclick = () => { $('#modal').classList.remove('on'); elegirCarpeta($('#btnElegir')); }; };
$('#btnElegir').onclick = () => elegirCarpeta($('#btnElegir'));
$('#btnElegir2').onclick = e => { e.stopPropagation(); elegirCarpeta($('#btnElegir2')); };
zona.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); zona.onclick(); } };
$('#vacioCaja').onclick = () => zona.onclick(); $('#vacioCaja').onkeydown = zona.onkeydown;
function proponerRaiz(ruta){