2.1.0: el asistente se fusiona con la lista de acciones

Había dos caminos para lo mismo. Ahora encima de la lista sale «Hacer las N de
una vez», que ejecuta seguidas las acciones que no necesitan elegir nada, con
el mismo motor que tenía el asistente. Convertir y respaldar se quedan fuera
porque hay que elegir; lo dudoso nunca entra.

- POST /api/acciones/hacer; acciones() trae  y el progreso.
- Se quita el punto de entrada «Asistente paso a paso» de la interfaz (el
  motor y la entrada por línea de comandos siguen).
- Las acciones de ordenar no cuentan como pendientes sin un plan calculado:
  tras ordenarlo todo la lista seguía diciendo que faltaban dos cosas.

Probado de punta a punta: 19 archivos antes y después, ninguno perdido, árbol
completo ordenado en un clic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 21:53:50 -05:00
padre 6b8bc23941
commit 1e1187d28a
Se han modificado 4 ficheros con 69 adiciones y 21 borrados

Ver fichero

@@ -379,6 +379,20 @@ el inventario, así que tarda entre 0,04 y 0,23 s en las carpetas reales; el pla
ya estaba calculado, y si no el botón dice «Calcular». De paso se conecta a la interfaz la detección de
material repetido, que funcionaba por dentro pero no tenía dónde pulsarse, y `_gb` pasa a dar terabytes.
2.1.0, el asistente se fusiona con la lista. Había dos formas de hacer lo mismo: la lista de acciones y un
«Asistente paso a paso» que recorría lo mismo preguntando. Ahora hay una sola: encima de la lista sale
**«Hacer las N de una vez»**, que ejecuta seguidas las acciones que no necesitan que elijas nada (apartar
basura y proxies, ordenar por fecha, ordenar lo demás, apartar stock viejo, juntar repetidos) usando el mismo
motor que tenía el asistente. Se quedan fuera convertir y respaldar, que tienen su propio botón porque hay
que elegir. Lo dudoso nunca entra: se queda como está para que alguien lo mire.
Probado de punta a punta sobre un árbol de ensayo: 19 archivos antes y después, ninguno perdido, y en un solo
clic el material de cámara quedó repartido por mes y fuente, las piezas en su mes, la música en `_Recursos` y
la basura, los proxies y el stock viejo en `_Para borrar`. Endpoint `POST /api/acciones/hacer`; la lista de
acciones trae `automatico` (las que se pueden hacer solas) y el progreso del motor. De paso, las acciones de
ordenar dejan de contar como pendientes mientras no haya un plan calculado: después de ordenarlo todo la
lista seguía diciendo que faltaban dos cosas que en realidad ya no tenían nada.
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": "2.0.0",
"builtAt": "2026-09-16T16:44:23.000Z",
"archivo": "OrganizadorLabstream-portatil-2.0.0.zip",
"size": 198696162,
"sha256": "dbde9ca9d11f8b2620cea2ba216cf4705c3ddd66b1310cbaf0935a7a8a0345c1",
"version": "2.1.0",
"builtAt": "2026-09-17T02:51:40.000Z",
"archivo": "OrganizadorLabstream-portatil-2.1.0.zip",
"size": 198698005,
"sha256": "1ba2cd2dde181b42c6de5cc0860fa52d6d201c874bb1b28285200f984c240bf4",
"app": {
"archivo": "OrganizadorLabstream-app-2.0.0.zip",
"size": 14876413,
"sha256": "f27d51517adaad20dd43a07697049a5b44469b846db65d47f0b0be97e8e4294e",
"url": "/api/herramientas/OrganizadorLabstream-app-2.0.0.zip"
"archivo": "OrganizadorLabstream-app-2.1.0.zip",
"size": 14878256,
"sha256": "381cbd045a79c4ed920d4101aabb8b234034f93b99b816732bc8b5c2dd7d73b1",
"url": "/api/herramientas/OrganizadorLabstream-app-2.1.0.zip"
},
"notes": "",
"firma": {
"alg": "rsa2048-pkcs1v15-sha256",
"valor": "09b248add12c0bc1ed332398801452ebf3dba6a74bf6e6f94909c5dfbb74a00b85f8aa393ee3edffa700d95b381ba918dbaa0b991d9646963d0b0508a76f4904f952ed2bdf2a4bc8aa66e7ea4edc1b6ef11c04bc1d959a93aca309937db7b662c698d9356984dec3f4a1033e4f844cdc86730a1cd5228a32d40c9ce997453379da96e3055a24fd511e3062cbea029cccf54f05fc95e16cc257abd2fc53cfdcda7bf81695daabbb2e905bcfb378fa1c394b341b70f8e3048c0afb1bba4a99ce53b19433c50ea419e6420a6e17d279b721b56a565d408c133b8cb71de22fdd6b67ead86db2fa80f2c9749d584e7b8b17f2c42d631881695a6efd5d6daf76ecff22"
"valor": "944a227ccc3edf2bc3038ca2d6ab507efac49c5b3384df1a6659073e9a88e84e853b73617e47f03ad53bbac04a24bb37082d38bdc918b6c22bd8c93158d75ec34190abe1fa082b8eb0bf3efb80a42dd80bbac6e1b28d839675654f674e82ab8023eb10b76d1df198cb14bcd847edee12b74f89851160937768981be8e327ae151b94bf1c38339d1e0210a653d593f54d26a3c6140f0401dcfe0b039fbf20b188a14f263f19242824f1c351f3837c5bd07d486a0c5e18376f3c30e2e1e61013e18b66883b659a326f5c38a875527372013ec111c579ddd50ee7d3c4aa4fe757971b3b022cc9ea004472db397d5ab189a29c5450ba8b7b43a8c5f767b0b7d3087e"
}
}

Ver fichero

@@ -34,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "2.0.0"
VERSION = "2.1.0"
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
@@ -2782,8 +2782,10 @@ def acciones(raiz):
else:
añadir(clave=clave, titulo=titulo, estado="hecho", que="Nada que mover.", cifra="", detalle="")
else:
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="",
detalle="Hay que calcular el plan para saber cuánto es.", ir="plan", paso=clave)
# sin el plan calculado no se sabe si queda algo: se ofrece calcularlo, pero no cuenta como
# pendiente (si no, después de ordenarlo todo la lista seguía diciendo que faltaban dos cosas)
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera",
detalle="Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave)
# --- 5. stock de Envato viejo ---
tope = limite_meses(3)
@@ -2842,7 +2844,10 @@ def acciones(raiz):
ir="respaldar")
con.close()
return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc)}
# «lo seguro» son las acciones que se pueden hacer sin elegir nada: no entran convertir ni respaldar
auto = [a["clave"] for a in out if a["estado"] == "toca" and a["clave"] in ("limpiar", "fechas", "resto", "stock", "repetidos")]
return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc),
"automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None}
def estado_carpeta(ruta):
@@ -3147,6 +3152,21 @@ class Manejador(BaseHTTPRequestHandler):
CONFIG["tour_visto"] = True
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True})
if ruta == "/api/acciones/hacer":
# «Hacer todo lo seguro»: el mismo motor que tenía el asistente, pero lanzado desde la
# lista de acciones y sin preguntar nada. Deja fuera lo que no debe ir en automático:
# convertir (tarda horas y hay que elegir formato) y respaldar (necesita otro disco).
raiz = os.path.normpath(d["raiz"])
if asistente.ESTADO["activo"]:
return self._json({"error": "ya hay algo en marcha en esta carpeta"}, 409)
diag = asistente.diagnostico(raiz)
filas = Plan(raiz_cfg(raiz)).correr()["filas"]
dec = {p["clave"]: p["defecto"] for p in asistente.pasos(diag, filas)}
dec["convertir"] = "no"
dec["respaldo"] = "no"
dec["carpetas"] = "seguras" # nunca las dudosas sin que alguien las mire
asistente.ejecutar(raiz, dec, filas, en_hilo=True)
return self._json({"lanzado": True, "hara": {k: v for k, v in dec.items() if v not in ("no", "dejar")}})
if ruta == "/api/asistente":
raiz = os.path.normpath(d["raiz"])
if asistente.ESTADO["activo"]:

Ver fichero

@@ -327,7 +327,7 @@
</div>
</div>
<div id="inicioLleno">
<div class="cab"><div><h2 class="v" id="cabTitulo">…</h2><div class="sub" id="cabSub"></div></div><div class="der"><button class="lnk" id="btnEscanear">Volver a escanear</button><button class="lnk" id="btnAsistente">Asistente paso a paso</button><button class="lnk" id="btnDesconectar" title="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 class="cab"><div><h2 class="v" id="cabTitulo">…</h2><div class="sub" id="cabSub"></div></div><div class="der"><button class="lnk" id="btnEscanear">Volver a escanear</button><button class="lnk" id="btnDesconectar" title="La carpeta deja de aparecer en la lista de la izquierda. No borra ni mueve nada del disco.">Quitar de la lista</button></div></div>
<div id="acciones" class="acc"></div>
<div class="sig" id="siguiente" style="display:none"></div>
<div class="row">
@@ -588,7 +588,8 @@ function detectarCambios(){
fin('esc:' + raiz, 'Escaneo terminado.');
if (antes['ana:' + raiz] && !ahora['ana:' + raiz]) { if (BUSCANDO || vistaActual() === 'convertir') { BUSCANDO = false; irA('convertir'); cargarCandidatos().catch(e => aviso(e.message, 'bad')); } else aviso('Lectura de clips terminada.', 'ok', {texto: 'Ver clips', ir: () => { irA('convertir'); cargarCandidatos(); }}); }
fin('cola', 'Conversión terminada.', {texto: 'Ver', ir: () => irA('convertir')});
fin('asis', 'El asistente terminó.' + (ESTADO.asistente.resultado && ESTADO.asistente.resultado.errores && ESTADO.asistente.resultado.errores.length ? ` ${ESTADO.asistente.resultado.errores.length} avisos.` : ''), {texto: 'Ver historial', ir: () => irA('historial')});
if (antes.asis && !ahora.asis && vistaActual() === 'carpeta') pintarAcciones().catch(() => {});
fin('asis', 'Listo: se hizo todo lo seguro de esa carpeta.' + (ESTADO.asistente.resultado && ESTADO.asistente.resultado.errores && ESTADO.asistente.resultado.errores.length ? ` ${ESTADO.asistente.resultado.errores.length} avisos.` : ''), {texto: 'Ver historial', ir: () => irA('historial')});
fin('resp', 'Copia de respaldo terminada.', {texto: 'Ver', ir: () => irA('respaldar')});
if ((antes.cola !== ahora.cola || ahora.cola) && vistaActual() === 'convertir' && !document.querySelector('#convLista input:focus')) cargarCola();
}
@@ -736,9 +737,24 @@ async function pintarAcciones(){
if (raizActual() !== raiz) return; // cambió de carpeta mientras llegaba
ACCIONES = d.acciones || [];
const quedan = ACCIONES.filter(a => a.estado === 'toca').length;
$('#acciones').innerHTML = `<div class="cabsec">${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día'}</div>` +
const auto = d.automatico || [], asis = d.asistente;
const corriendo = asis && asis.activo;
const cab = corriendo
? `<div class="cabsec">Trabajando en esta carpeta<span class="sep"></span><span class="mut" style="text-transform:none;letter-spacing:0">${esc(NOMBRE_PASO[asis.paso] || asis.paso || '')} · ${asis.hechas || 0} de ${asis.total || 0}</span></div>`
: `<div class="cabsec">${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día'}${auto.length > 1 ? '<span class="sep"></span><button class="p" id="btnHacerTodo" style="text-transform:none;letter-spacing:0">Hacer las ' + auto.length + ' de una vez</button>' : ''}</div>`;
$('#acciones').innerHTML = cab +
ACCIONES.map((a, i) => `<div class="ac ${a.estado}"><span class="ico">${a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}</span><div class="tx"><b>${esc(a.titulo)}</b><div class="q">${esc(a.que)}</div>${a.detalle ? `<div class="d">${esc(a.detalle)}</div>` : ''}</div><div class="der">${a.cifra ? `<span class="cifra">${esc(a.cifra)}</span>` : ''}${a.boton ? `<button class="${a.estado === 'espera' ? 'lnk' : 'p'}" data-a="${i}">${esc(a.boton)}</button>` : ''}</div></div>`).join('');
$$('#acciones [data-a]').forEach(b => b.onclick = () => { const a = ACCIONES[+b.dataset.a]; (IR_ACCION[a.ir] || (() => {}))(a.paso); });
const bt = $('#btnHacerTodo');
if (bt) bt.onclick = () => modal('Hacer todo lo seguro de una vez',
`<p>La app va a hacer seguidas las <b>${auto.length}</b> cosas de la lista que no necesitan que elijas nada, en orden y sin preguntar.</p>
<p>Solo mueve lo que considera seguro: <b>lo dudoso se queda como está</b> para que lo mires tú. Todo se queda dentro de <span class="path">${esc(raiz)}</span>, nada se borra, y cada cambio se deshace desde el Historial.</p>
<p class="mut" style="font-size:13px">No entran aquí convertir los clips ni copiar el respaldo: esos tienen su propio botón porque hay que elegir cosas.</p>`,
() => accion(bt, async () => {
await api('/api/acciones/hacer', {raiz});
aviso('En marcha. El avance está en la barra de abajo y aquí arriba.', 'ok');
await cargarEstado(); await pintarAcciones();
}));
}
function queSeHizo(lote){
if (!lote) return 'Cambio';
@@ -785,13 +801,12 @@ function pasosDe(C, r){
function pintarSiguiente(P){
const C = CARPETA; const s = {t:'', p:'', acts:[]};
const b = (txt, fn, p) => s.acts.push({txt, fn, p});
const asis = () => asistente(raizActual(), false);
if (P[0].e === 'curso') { s.t = 'Escaneando la carpeta'; s.p = 'Está leyendo nombres, tamaños y fechas. No toca nada. Cuando termine, aquí aparece el diagnóstico.'; }
else if (P[0].e === 'pend') { s.t = 'Empieza por escanear'; s.p = 'La app lee los nombres, tamaños y fechas de todos los archivos. No mueve nada. En una carpeta grande del NAS tarda unos minutos.'; b('Escanear ahora', () => $('#btnEscanear').click(), true); }
else if (P[2].e === 'curso') { s.t = 'El asistente está trabajando'; s.p = 'Puedes seguir el avance en la barra de abajo.'; }
else if (P[1].e === 'listo' && !C.plan && P[2].e !== 'hecho') { const n = C.sin_nombre_n; s.t = `Antes de ordenar: nombra ${n} carpeta${n > 1 ? 's' : ''}`; s.p = `La app no sabe qué son ${C.sin_nombre.slice(0, 4).map(x => '«' + x.ejemplo + '»').join(', ')}${n > 4 ? ' y otras' : ''}. Si le dices qué es cada una, las ordena bien y se lo aprende para todos los discos. Si prefieres, sáltalo: quedarán como «dudoso» y no se moverán.`; b('Nombrar las carpetas', () => { irA('revisar'); subRev('nombrar'); }, true); b('Saltar y calcular', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }); b('Asistente paso a paso', asis); }
else if (P[1].e === 'listo' && !C.plan && P[2].e !== 'hecho') { const n = C.sin_nombre_n; s.t = `Antes de ordenar: nombra ${n} carpeta${n > 1 ? 's' : ''}`; s.p = `La app no sabe qué son ${C.sin_nombre.slice(0, 4).map(x => '«' + x.ejemplo + '»').join(', ')}${n > 4 ? ' y otras' : ''}. Si le dices qué es cada una, las ordena bien y se lo aprende para todos los discos. Si prefieres, sáltalo: quedarán como «dudoso» y no se moverán.`; b('Nombrar las carpetas', () => { irA('revisar'); subRev('nombrar'); }, true); b('Saltar y calcular', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }); }
else if (C.plan) { s.t = `Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`; s.p = `Todo se mueve dentro de la misma carpeta y nada se borra. ${C.plan.baja ? C.plan.baja + ' son dudosos y están sin marcar. ' : ''}Revisa la lista y aplica; se puede deshacer desde Historial.`; b('Revisar y aplicar', () => { irA('revisar'); subRev('plan'); }, true); }
else if (P[2].e === 'pend') { s.t = 'Siguiente: ordenar la carpeta'; s.p = 'Calcula qué va a pasar (basura y proxies a _Para borrar, grabaciones por mes y cámara, piezas numeradas) y aplícalo con un clic. O deja que el asistente te pregunte paso a paso.'; b('Calcular qué va a pasar', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }, true); b('Asistente paso a paso', asis); }
else if (P[2].e === 'pend') { s.t = 'Siguiente: ordenar la carpeta'; s.p = 'Calcula qué va a pasar (basura y proxies a _Para borrar, grabaciones por mes y cámara, piezas numeradas) y aplícalo con un clic. O deja que el asistente te pregunte paso a paso.'; b('Calcular qué va a pasar', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }, true); }
else if (P[3].e === 'listo') { const d = P[3].d; s.t = 'Siguiente: ' + (d.includes('verificar') ? 'verificar los clips convertidos' : d.includes('reemplazar') ? 'reemplazar los originales' : 'retomar la conversión'); s.p = d.includes('verificar') ? 'Verificar comprueba códec, 10 bits y duración. Después puedes reemplazar los originales (quedan en _Para borrar/originales).' : d.includes('reemplazar') ? 'Los originales se apartan a _Para borrar/originales y el MP4 10 bits queda en su lugar. Nada se borra.' : 'Hay clips en cola con la conversión parada.'; b('Ir a Convertir', () => irA('convertir'), true); }
else if (P[3].e === 'curso') { s.t = 'Convirtiendo clips'; s.p = 'Sigue en segundo plano. El avance está en la barra de abajo; cuando termine, toca reemplazar.'; b('Ver la cola', () => irA('convertir')); }
else if (P[3].e === 'pend') { const cv = C.convertir; s.t = 'Siguiente: aligerar el material de cámara'; s.p = `Hay ${gb(cv.camara_bytes)} de clips de cámara. Los de proyectos ya entregados pueden pasar a MP4 10 bits, unas 10 veces más ligeros. ${cv.raw_bytes ? 'Los BRAW necesitan DaVinci Resolve Studio abierto. ' : ''}El original no se toca hasta que reemplaces.`; b('Buscar clips a convertir', () => { irA('convertir'); $('#btnBuscarClips').click(); }, true); b('Saltar a respaldar', () => irA('respaldar')); }
@@ -1261,7 +1276,6 @@ const asistente = seguro(async (raiz, escanearAntes) => {
$('#modalSi').textContent = 'Empezar';
});
const NOMBRE_PASO = {plan: 'pensando el plan', ordenar: 'ordenando carpetas y archivos', stock: 'apartando stock viejo', duplicados: 'juntando repetidos', analizar: 'leyendo los clips', convertir: 'convirtiendo a 10 bits', respaldo: 'copiando el respaldo', fin: 'listo'};
$('#btnAsistente').onclick = () => asistente(raizActual(), false);
// ---------------- modal ----------------
function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }