diff --git a/README.md b/README.md
index 4c7017b..e310e3e 100644
--- a/README.md
+++ b/README.md
@@ -880,6 +880,41 @@ Pruebas: `prueba_orden.py` 24 casos (5 nuevos: pieza, bandeja, tarjeta con una c
que rechazar, versiones con ffprobe simulado, y la pasada automática con sus frenos); `prueba_funcional.py`
29 de 29 (3 nuevas, por la interfaz de verdad del servidor); `probar.py` 19 y `prueba_ventana.py` 18.
+3.5.0, «Qué va a pasar» se explica con una carpeta. Munka, con el plan de Diana Méndez delante: «se lee
+mucho pero se entiende poco», «lo de ordenar por año y por mes, ¿me toca seleccionar uno a uno o lo hace
+todo?», y quería una barra para saber por dónde va y si hace falta algo. Lo que había: tres cajas con un
+párrafo cada una, 46 filas con casilla y dos líneas de motivo por fila, y la mitad sin marcar sin decir por qué
+(eran los renombrados cosméticos de «01 - Celular» a «01- Celular»). Ahora:
+
+- **Una frase arriba** («La app va a hacer 66 cambios (1,9 TB) dentro de F:\Diana Mendez. Nada se borra…») y un
+ solo botón, «Aplicar los 66 cambios», con la aclaración al lado: un clic lo hace todo, en orden, no hay que
+ ir uno por uno. Los avisos van plegados bajo «6 cosas que conviene mirar antes de aplicar».
+- **Tres fichas**, una por paso, con la cuenta de cambios, el peso y cuántos quedan sin marcar; se pulsa una y
+ debajo se ve ese paso. Cada ficha tiene su «solo este paso».
+- **Un ejemplo animado**: la fila más representativa del paso (lo que se mueve antes que lo que se renombra, y
+ lo más pesado primero), dibujada como dos ramas de carpetas, «Ahora» y «Después», y lo que se mueve (la
+ carpeta, o un chip con «94 archivos») vuela de una rama a la otra; debajo, la frase y el porqué. «Otro
+ ejemplo» recorre las filas, y en la lista, «ejemplo» sube cualquiera. Con «menos movimiento» en Windows, o
+ si la pestaña no se ve, se enseña el resultado sin animar.
+- **La lista, corta**: de dónde → a dónde y la cuenta, sin el motivo (se pide con «ver por qué»); «seguro» no
+ lleva etiqueta, «probable» y «dudoso» sí. Lo que no va solo queda aparte y contado: «Sin marcar · 46: 44
+ solo cambian el nombre a la forma «NN- Título»: da igual hacerlo o no; 2 son dudosos», con «marcar los
+ cosméticos» y «ver la lista».
+- **La barra al aplicar**: el servidor cuenta el avance archivo a archivo (`/api/aplicando`: unidades,
+ movimientos, fallos y qué se mueve ahora) y la interfaz lo sondea cada 0,4 s mientras `/api/aplicar` corre.
+ Al acabar: «Listo. 66 de 66 cambios · 1.234 movimientos · 1,9 TB · 48 s», «Deshacer esto», los que no se
+ pudieron con «ver cuáles», y qué queda sin hacer según el plan recién recalculado (los cosméticos y los
+ dudosos, o «la carpeta está en orden»). La barra de tareas de Windows avanza con la misma cuenta.
+- Cuando lo seguro ya está hecho (el caso de Diana Méndez al escribir esto: 43 cosméticos y 5 dudosos), la
+ frase de arriba lo dice así, en vez de un botón «Aplicar» apagado sin explicación.
+
+También del 10 de octubre: en «Añadir carpeta», la clase de trabajo se elige entre cuatro tarjetas (título,
+cómo se ordena y «como David Reyes, Danney Gómez y 3 más», los clientes que ya van así) en vez de un
+desplegable, y el ejemplo de cómo queda es un árbol de carpetas, no una lista de rutas completas.
+
+Pruebas: `prueba_funcional.py` comprueba además que el avance acaba en «tantas de tantas» con los movimientos
+de la respuesta; `probar.py` 19, `prueba_orden.py` 33, `prueba_funcional.py` 30 y `prueba_ventana.py` 18.
+
3.4.0, el convertidor: los BRAW salen, y lo que ya viene comprimido no se toca. Munka avisó de que había
archivos que no podía reconvertir y que antes sí, y de lo que quiere: 10 bits de color, buena calidad y mucho
menos peso. La auditoría, sobre la carpeta de Andrea Galeano el 5-oct-2026:
diff --git a/organizador.py b/organizador.py
index d1d204b..83208da 100644
--- a/organizador.py
+++ b/organizador.py
@@ -42,7 +42,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
-VERSION = "3.4.0"
+VERSION = "3.5.0"
BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
@@ -1859,7 +1859,11 @@ def mover(origen, destino, lote, movidos):
movidos.append((origen, destino))
-APLICANDO = {"hechas": 0, "total": 0, "actual": ""}
+# hechas/total cuentan filas del plan (es lo que miran «en marcha» y la barra de tareas); unidades cuenta
+# archivo a archivo dentro de las filas de archivos sueltos, para que la barra avance suave en una fila de
+# 400 clips; actual/archivo dicen qué se está moviendo; lote/fin, qué tanda fue y cuándo acabó.
+APLICANDO = {"hechas": 0, "total": 0, "actual": "", "archivo": "", "unidades": 0, "unidades_total": 0,
+ "movidos": 0, "fallos": 0, "lote": "", "inicio": 0, "fin": 0}
def aplicar(raiz, filas):
@@ -1868,9 +1872,12 @@ def aplicar(raiz, filas):
errores = []
hechas = 0
filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
- APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz))
+ unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas)
+ fallos = 0
+ APLICANDO.update(hechas=0, total=len(filas), actual="", archivo="", raiz=_clave(raiz), unidades=0,
+ unidades_total=unidades_total, movidos=0, fallos=0, lote=lote, inicio=time.time(), fin=0)
for f in filas:
- APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "")
+ APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "", archivo="")
if f.get("archivos"):
# cada archivo va por su cuenta: antes, un clip abierto en Resolve cortaba el bucle y los
# ciento y pico restantes de esa misma fila no se intentaban, mientras el resumen decía
@@ -1884,6 +1891,7 @@ def aplicar(raiz, filas):
errores.append(f"{f.get('origen_rel')}: no se pudo crear la carpeta destino: {ex}")
continue
for a in f["archivos"]:
+ APLICANDO.update(unidades=APLICANDO["unidades"] + 1, archivo=os.path.basename(a))
if not os.path.exists(a):
continue
try:
@@ -1892,6 +1900,8 @@ def aplicar(raiz, filas):
fallos_fila += 1
if fallos_fila <= 5:
errores.append(f"{os.path.basename(a)}: {ex}")
+ APLICANDO.update(movidos=len(movidos), fallos=fallos + fallos_fila)
+ fallos += fallos_fila
if fallos_fila > 5:
errores.append(f"{f.get('origen_rel')}: y otros {fallos_fila - 5} archivos que tampoco se pudieron mover")
if not fallos_fila:
@@ -1902,10 +1912,15 @@ def aplicar(raiz, filas):
mover(f["origen"], f["destino"], lote, movidos)
hechas += 1
else:
+ fallos += 1
errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)")
except Exception as ex: # noqa
+ fallos += 1
errores.append(f"{f.get('origen_rel')}: {ex}")
- APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
+ APLICANDO.update(unidades=APLICANDO["unidades"] + 1, movidos=len(movidos), fallos=fallos)
+ # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
+ APLICANDO.update(hechas=len(filas), actual="", archivo="", unidades=unidades_total, movidos=len(movidos),
+ fallos=fallos, fin=time.time())
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
for f in filas:
base = f["origen"] if f.get("archivos") else os.path.dirname(f["origen"])
@@ -5583,6 +5598,8 @@ class Manejador(BaseHTTPRequestHandler):
return
if ruta == "/api/estado":
return self._json(estado_general())
+ if ruta == "/api/aplicando": # la barra de «Aplicar», sondeada mientras corre
+ return self._json(dict(APLICANDO))
if ruta == "/api/soltado":
r = soltado(q.get("nombre", ""))
return self._json({"ruta": r, "es_archivo": bool(r) and os.path.isfile(r)})
@@ -6225,7 +6242,7 @@ def avance():
if AUTO.get("activo"):
return AUTO["hechas"] / AUTO["total"] if AUTO.get("total") else -1
if APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]:
- return APLICANDO["hechas"] / APLICANDO["total"]
+ return APLICANDO["unidades"] / APLICANDO["unidades_total"] if APLICANDO.get("unidades_total") else APLICANDO["hechas"] / APLICANDO["total"]
if asistente.ESTADO.get("activo"):
return asistente.ESTADO["pct"] / 100 if asistente.ESTADO.get("total") else -1
for a in list(ANALISIS.values()):
diff --git a/prueba_funcional.py b/prueba_funcional.py
index de4fe15..214601b 100644
--- a/prueba_funcional.py
+++ b/prueba_funcional.py
@@ -235,11 +235,13 @@ def p_aplicar():
assert not r['errores'], r['errores']
assert r['movimientos'] > 0, 'no movió nada'
LOTES['orden'] = r['lote']
+ av = get('/api/aplicando')
+ assert av['lote'] == r['lote'] and av['fin'] and av['unidades'] == av['unidades_total'] > 0 and av['movidos'] == r['movimientos'], av
despues = arbol()
assert sorted(os.path.basename(x) for x in antes) == sorted(os.path.basename(x) for x in despues), 'se perdió algún archivo'
assert '10- Octubre' in os.listdir(os.path.join(RAIZ, '2026')), 'el mes sigue en minúsculas: %s' % os.listdir(os.path.join(RAIZ, '2026'))
assert not any('thumbs.db' in x and '_para borrar' not in x for x in despues), 'la basura sigue fuera'
- return '%d movimientos, sin errores, «10- octubre» pasó a «10- Octubre»' % r['movimientos']
+ return '%d movimientos, sin errores, «10- octubre» pasó a «10- Octubre»; el avance acabó en %d de %d' % (r['movimientos'], av['unidades'], av['unidades_total'])
def p_deshacer():
diff --git a/static/index.html b/static/index.html
index cc8b5fb..0aa1a05 100644
--- a/static/index.html
+++ b/static/index.html
@@ -255,24 +255,81 @@
.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} }
- .paso{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:10px}
- .paso .pc{display:flex;gap:12px;align-items:center;padding:12px 14px}
- .paso .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;flex:none;font-size:13px}
- .paso.vacio .num{background:var(--ok2);color:var(--ok)}
- .paso .pc .tx{flex:1;min-width:0}
- .paso .pc .tx b{font-size:14px}
- .paso .pc .tx .mut{font-size:12.5px;margin-top:2px}
- .paso .pc .der{display:flex;gap:8px;align-items:center;flex:none}
- .paso .res{font-size:12.5px;color:var(--mut);white-space:nowrap}
- .paso .detalle{border-top:1px solid var(--line2);padding:6px 14px 10px;display:none}
- .paso.abierto .detalle{display:block}
- .paso .fila{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:10px;align-items:start;padding:7px 0;border-bottom:1px solid var(--line2);font-size:13px}
- .paso .fila:last-child{border-bottom:0}
- .paso .fila .de{color:var(--mut);font-size:12px;margin-top:2px}
- .paso .fila.baja .frase{color:var(--mut)}
- .paso .fila .ed{border:0;background:none;color:var(--acc);cursor:pointer;font-size:12px;padding:0 4px}
- .paso .grp{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);padding:10px 0 4px;font-weight:700}
#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:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .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:hidden;text-overflow:ellipsis;white-space:nowrap;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}
@@ -428,16 +485,20 @@
-
Pulsa Calcular para ver, paso a paso, qué haría la app en esta carpeta.
+
Pulsa Calcular 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.
+
+ Un clic lo hace todo, en orden. No hay que ir uno por uno.
-
+
-
+
+
+
@@ -592,7 +653,7 @@
Qué hay
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.
Por nombrar
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. 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.
-
Qué va a pasar
El plan: a dónde iría cada cosa, sin mover nada. Va en tres pasos que se aplican por separado: apartar basura y proxies, ordenar por año y mes (y por día de rodaje si ese mes hubo más de uno), y ordenar lo demás (proyectos por programa, exportados, música…). 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 fila va marcada como segura, por revisar o dudosa.
+
Qué va a pasar
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. 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.
Grabaciones
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. 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.
Cajones
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.
Repaso de lo ya ordenado
El plan también mira lo que ya está en 00_Grabacion: si un clip lleva en el nombre una fecha que no es la del mes donde está, propone llevarlo al suyo. Cómo: solo con fechas escritas por la cámara; la fecha de modificación no cuenta, salvo para lo que se grabó ese mismo día.
@@ -754,7 +815,7 @@ function cfgRaiz(){ return (ESTADO.raices||[]).find(r => r.clave === raizActual(
function irA(t){ $$('nav a').forEach(x => x.classList.toggle('on', x.dataset.t === t)); $$('section').forEach(s => s.classList.toggle('on', s.id === 't-' + t)); pintarIndice(); cargarVista(t); }
function vistaActual(){ return ($$('nav a').find(a => a.classList.contains('on')) || {}).dataset?.t || 'carpeta'; }
$$('nav a').forEach(a => { a.onclick = () => irA(a.dataset.t); a.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); irA(a.dataset.t); } }; });
-function subRev(s){ $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); }
+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();
@@ -920,7 +981,7 @@ function tareasDe(E){
});
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.hechas * 100 / E.aplicando.total)});
+ 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}); }
@@ -1036,7 +1097,7 @@ async function cargarCarpeta(){
// 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'); subRev('plan'); if (!PLAN) $('#btnPlan').click(); else if (p) { PLAN_ABIERTOS = new Set([p]); pintarPlan(); } },
+ 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(); },
@@ -1261,93 +1322,200 @@ function pintarReglas(reglas){
$$('#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 (paso a paso, en frases) ----------------
+// ---------------- 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 (.DS_Store, Thumbs.db, cachés) y proxies se apartan en «_Para borrar». Los proxies se regeneran desde Resolve o Premiere. Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'},
- {k: 'fechas', t: 'Ordenar por año y mes', q: 'Los meses toman el nombre «03- Marzo», las grabaciones se reparten por mes y por cámara, y las piezas quedan numeradas dentro de su mes. Lo que ya estaba ordenado en un mes que no es el de su rodaje vuelve al suyo.', vacio: 'Las fechas ya están en orden.'},
- {k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música, marca y demás van a su carpeta. Lo dudoso queda sin marcar: la app no lo mueve salvo que lo marques tú.', vacio: 'Nada más que ordenar.'},
+ {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_ABIERTOS = new Set(), ACCIONES = [];
+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 ? `Aplicar todo lo marcado (${seg.length})` : 'Aplicar todo lo seguro';
+ 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 = ' mirando la carpeta…';
PLAN = await api('/api/plan?raiz=' + encodeURIComponent(raizActual()));
+ PLAN_EJ = 0; PLAN_VER_OPC = false;
pintarPlan();
}
-$('#btnPlan').onclick = () => accion($('#btnPlan'), calcularPlan);
+$('#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)');
+ 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(x.split(/[\\/]/).pop())}»`;
+ 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)}»`;
}
-// actualiza solo las cifras de cada paso y los botones, sin tocar las filas: así el foco se queda
-// donde está y se puede recorrer la lista con el teclado
-function actualizarContadores(){
- if (!PLAN) return;
- PASOS_PLAN.forEach(p => {
- const caja = $(`#planPasos .paso[data-k="${p.k}"]`); if (!caja) return;
- const rs = PLAN.filas.filter(r => (r.paso || 'resto') === p.k), marc = rs.filter(r => r.marcado);
- const res = caja.querySelector('.res');
- if (res) res.textContent = `${rs.length} cambio${rs.length > 1 ? 's' : ''} · ${gb(rs.reduce((a, r) => a + r.bytes, 0))}${marc.length < rs.length ? ` · ${marc.length} marcado${marc.length !== 1 ? 's' : ''}` : ''}`;
- const bt = caja.querySelector('[data-aplicar]');
- if (bt) { bt.disabled = !marc.length; bt.textContent = 'Aplicar' + (marc.length ? ' ' + marc.length : ''); }
- });
- estadoPlan();
+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; }
- const f = ($('#filtroPlan').value || '').toLowerCase();
+ if (!PLAN_PASO || !filasPaso(PLAN_PASO).length) PLAN_PASO = pasoPorDefecto();
+ pintarCabPlan();
const avs = PLAN.avisos || [];
- $('#planAvisos').innerHTML = avs.length ? `
${avs.length === 1 ? 'Revisa esto antes de aplicar' : 'Revisa estas ' + avs.length + ' cosas antes de aplicar'}:
${avs.map(a => `
${esc(a)}
`).join('')}
` : '';
- const porPaso = {}; PASOS_PLAN.forEach(p => porPaso[p.k] = []);
- PLAN.filas.forEach(r => (porPaso[r.paso || 'resto'] = porPaso[r.paso || 'resto'] || []).push(r));
- const tot = PLAN.filas.length, totB = PLAN.filas.reduce((a, r) => a + r.bytes, 0), dud = PLAN.filas.filter(r => r.confianza === 'baja').length;
- const partes = PASOS_PLAN.filter(p => porPaso[p.k].length).map(p => `${p.t.toLowerCase()} (${porPaso[p.k].length})`);
- $('#planIntro').innerHTML = tot ? `Qué va a hacer la app: ${partes.join(', ')}. En total ${tot} cambio${tot > 1 ? 's' : ''} · ${gb(totB)}. Todo se queda dentro de${esc(PLAN.raiz)}y nada se borra.${dud ? ` ${dud} cambio${dud > 1 ? 's son dudosos y quedan' : ' es dudoso y queda'} sin marcar.` : ''} Puedes aplicar cada paso por separado, en orden.` : `La carpeta ya está en orden. No hay nada que apartar ni mover.`;
- $('#planPasos').innerHTML = PASOS_PLAN.map((p, i) => {
- const rs = porPaso[p.k].filter(r => !f || (r.origen_rel + ' ' + r.destino_rel + ' ' + r.motivo).toLowerCase().includes(f)).sort((a, b) => ({alta: 0, media: 1, baja: 2}[a.confianza] - {alta: 0, media: 1, baja: 2}[b.confianza]) || b.bytes - a.bytes);
- const marc = rs.filter(r => r.marcado), abierto = PLAN_ABIERTOS.has(p.k) || !!f;
- const vacio = !porPaso[p.k].length;
- let det = '';
- if (abierto && rs.length) {
- const grupos = {}; rs.forEach(r => (grupos[r.cubeta] = grupos[r.cubeta] || []).push(r));
- det = Object.keys(grupos).map(g => `
Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.
`, 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(); })); });
+ $('#planAvisos').innerHTML = avs.length ? `${avs.length === 1 ? 'Una cosa que conviene mirar antes de aplicar' : avs.length + ' cosas que conviene mirar antes de aplicar'}
${avs.map(a => `
${esc(a)}
`).join('')}
` : '';
+ const dt = $('#planAvisos details'); if (dt) dt.ontoggle = () => PLAN_AVISOS = dt.open;
+ pintarEjemplo();
+ pintarLista();
estadoPlan();
}
-$('#filtroPlan').oninput = pintarPlan;
+// 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 ? `La carpeta ya está en orden. No hay nada que apartar ni mover.`
+ : !marc.length ? `Lo seguro ya está hecho. Quedan ${sin.length} cambio${sin.length > 1 ? 's' : ''} que la app no hace sola (${resumenOpc(sin)}). Están abajo, por si quieres marcarlos.`
+ : `La app va a hacer ${marc.length} cambio${marc.length > 1 ? 's' : ''} (${gb(marc.reduce((a, r) => a + r.bytes, 0))}) dentro de ${esc(PLAN.raiz)}. 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 `
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.
` : ''}
`;
+ 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 `${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}por toda la carpeta`;
+ if (r.archivos) return `${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''}en ${esc(r.origen_rel)}`;
+ return `${esc(ultimo(r.origen_rel))}en ${esc(padreDe(r.origen_rel) || 'la raíz')}`;
+}
+function aHtml(r){
+ if (r.accion === 'renombrar') return `pasa a llamarse ${esc(ultimo(r.destino_rel))}`;
+ 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 `
`;
+}
+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 => `
${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}
` + 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 = `
${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}
${grupos(marc)}${sin.length ? `
Sin marcar · ${sin.length}${explica}.
${cos.length ? `` : ''}
${PLAN_VER_OPC ? grupos(sin) : ''}
` : ''}
`;
+ $('#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', `
${fraseDe(r)}
Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.
`, 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 = `
`;
+ if (r.lote) $('#avDeshacer').onclick = () => deshacer(r.lote);
+ if (falt) $('#avErr').onclick = () => modal('No se pudieron mover', `
${esc(r.errores.join('\n'))}
`, 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;
- modal(titulo, `
Se mueven ${m.length} cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de ${esc(PLAN.raiz)}.
Nada se borra y se puede deshacer desde el Historial. Es un cambio de sitio dentro del mismo disco. Cierra Resolve y Premiere si tienen abierto material de esta carpeta para que no bloqueen archivos.