From 91618474a0d35fabeb87511f7071d5ecacc367b3 Mon Sep 17 00:00:00 2001 From: Labstream Date: Wed, 14 Oct 2026 05:15:22 -0500 Subject: [PATCH] Inicio dice lo que ya se hizo: el plan se olvida al mover y se vuelve a pensar solo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditoría de «mover y reorganizar». El motor movía bien, pero el plan en memoria solo se olvidaba al aplicar desde «Qué va a pasar»: tras «Hacer las N de una vez», apartar stock, juntar repetidos, reemplazar o deshacer, Inicio seguía ofreciendo lo ya hecho («5 cambios · Ver y aplicar», «Plan listo: 7 de 9») o, tras deshacer, daba por hecho lo que volvía a estar en desorden. Ahora cualquier movimiento olvida el plan y lo vuelve a pensar en segundo plano; un plan sin nada marcado es «hecho», cada ficha dice cuándo se hizo y cuántos movimientos, y el diagnóstico no cuenta como basura lo que ya está en _Para borrar. Co-Authored-By: Claude Fable 5.1 --- README.md | 28 +++++++++++ asistente.py | 15 ++++-- organizador.py | 117 +++++++++++++++++++++++++++++++++++++++----- prueba_funcional.py | 30 ++++++++++-- static/index.html | 24 ++++++--- 5 files changed, 189 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 297a66e..5a65b76 100644 --- a/README.md +++ b/README.md @@ -880,6 +880,34 @@ 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.6.0, Inicio dice lo que ya se hizo. Munka pidió auditar «mover y reorganizar»: «si hago el cambio debería +decir que el cambio se hizo y no mandarme para hacerlo y mostrarme lo que pasaría o lo que ahorraría cuando ya +lo había movido». Reproducido el 14-oct-2026 sobre una carpeta de ensayo: el motor mueve bien (plan → aplicar → +deshacer → volver a aplicar deja el árbol exacto y el inventario al día), pero Inicio no se enteraba: + +- **El plan en memoria no se olvidaba al mover.** Solo lo olvidaba «Aplicar» desde «Qué va a pasar». Tras «Hacer + las N de una vez», apartar stock, juntar repetidos, reemplazar originales o «Deshacer», Inicio seguía con el + plan de antes: «Ordenar por año y mes · 5 cambios · Ver y aplicar» y «Plan listo: 7 de 9 movimientos marcados» + con todo ya movido; y tras deshacer, al revés, «lo seguro ya está hecho» con la carpeta otra vez en desorden. + Ahora cualquier movimiento (`actualizar_inventario`, y borrar carpetas vacías) olvida el plan y lo vuelve a + pensar en segundo plano (`olvidar_plan`); mientras tanto Inicio dice «comprobando qué queda…» y se refresca + solo. «Calcular» y la pasada automática esperan ese recálculo en vez de recorrer la carpeta dos veces, y la + pasada automática deja su plan en memoria para que por la mañana Inicio diga qué quedó. Si se intenta aplicar + un plan ya olvidado, el servidor contesta 409 y la interfaz lo recalcula en vez de mover filas que ya no + existen. +- **Un plan sin nada marcado contaba como pendiente.** Inicio decía «Plan listo: 0 de 2 movimientos marcados» y + mandaba a «Revisar y aplicar». Ahora es «hecho»: «Último cambio hoy 05:12 · 9 mov. · 2 dudosos sin marcar», y + «Siguiente» pasa a convertir o a respaldar. +- **Cada ficha dice cuándo se hizo.** «Ordenar lo demás · Hecho hoy a las 05:12: 9 movimientos»; «Basura y + proxies · 2 archivos ya en _Para borrar (0 MB): se borran cuando tú quieras»; y arriba, «Esta carpeta está al + día · último cambio 14 oct». +- **El diagnóstico no cuenta como basura lo ya apartado.** Después de apartar decía «2 archivos de basura de + sistema» (más que antes, porque los proxies y el stock movidos también se marcan como basura). Lo que está en + _Para borrar es un grupo aparte, «ya apartado», con su frase: «ahí se quedan hasta que tú los borres». + +Pruebas: `prueba_funcional.py` comprueba, tras aplicar, deshacer y «hacer todo», lo que dice Inicio (hecho y +cuándo, nada vuelto a ofrecer, el diagnóstico sin contar lo apartado); 32 de 32. + 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 diff --git a/asistente.py b/asistente.py index c4f5052..2258a42 100644 --- a/asistente.py +++ b/asistente.py @@ -26,6 +26,7 @@ GRUPOS = [ ("stock_reciente", "Stock de Envato reciente", "tomas de Envato de piezas en curso"), ("proxies", "Proxies", "copias ligeras que Resolve/Premiere regeneran solas"), ("basura", "Basura de sistema y autoguardados", ".DS_Store, Thumbs.db, cachés, autosaves de After Effects"), + ("apartado", "Ya apartado en _Para borrar", "basura, proxies, stock viejo y originales reemplazados que la app ya apartó; se borran cuando tú quieras"), ("exportados", "Exportados / masters", "videos finales renderizados"), ("video_otro", "Otros videos", "videos que no son de cámara ni exportados (descargas, referencias, IA)"), ("proyectos", "Proyectos de edición y diseño", ".drp, .prproj, .aep, .psd, .ai…"), @@ -37,7 +38,12 @@ TITULO = {k: t for k, t, _ in GRUPOS} QUE_ES = {k: q for k, _, q in GRUPOS} -def _grupo(f, limite_stock): +def _grupo(f, limite_stock, papeleras=()): + # lo que ya está en _Para borrar no cuenta como basura, proxies o stock «por apartar»: ya se apartó. + # Antes, después de apartar, Inicio decía «2 archivos de basura» (más que antes de apartar, porque + # los proxies y el stock movidos también se marcan como basura) como si estuviera por hacer. + if papeleras and any(f["ruta"].lower().startswith(p) for p in papeleras): + return "apartado" if f["es_basura"] or f["rol"] == "basura": return "basura" if f["es_proxy"] or f["rol"] == "proxy": @@ -70,8 +76,9 @@ def diagnostico(raiz): """Resumen corto y sencillo de lo que hay en la carpeta, por tipo de archivo.""" raiz = os.path.normpath(raiz) con = O.db() - filas = con.execute("SELECT rol, bytes, mtime, es_basura, es_proxy, es_stock, contexto, fecha FROM archivos WHERE raiz = ?", + filas = con.execute("SELECT rol, bytes, mtime, es_basura, es_proxy, es_stock, contexto, fecha, ruta FROM archivos WHERE raiz = ?", (raiz,)).fetchall() + papeleras = tuple(os.path.join(raiz, p).lower() + os.sep for p in O.R.PAPELERAS) esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone() con.close() meses = int(O.CONFIG["optimizar"].get("meses_minimos", 3)) @@ -79,7 +86,7 @@ def diagnostico(raiz): acc = {k: {"clave": k, "titulo": TITULO[k], "que_es": QUE_ES[k], "n": 0, "bytes": 0, "desde": None, "hasta": None} for k, _, _ in GRUPOS} total_b = 0 for f in filas: - g = acc[_grupo(f, limite)] + g = acc[_grupo(f, limite, papeleras)] g["n"] += 1 g["bytes"] += f["bytes"] or 0 total_b += f["bytes"] or 0 @@ -103,6 +110,8 @@ def diagnostico(raiz): frases.append(f"Los proxies suman {O._gb(b('proxies'))}: se regeneran solos, no hace falta guardarlos.") if acc["basura"]["n"]: frases.append(f"{acc['basura']['n']:,} archivos de basura de sistema o autoguardado ({O._gb(b('basura'))}).") + if acc["apartado"]["n"]: + frases.append(f"{acc['apartado']['n']:,} archivos ya apartados en «{O.R.PAPELERA}» ({O._gb(b('apartado'))}): ahí se quedan hasta que tú los borres.") dup = O.duplicados(raiz) if dup["grupos"]: frases.append(f"Hay {dup['n']} archivos repetidos ({O._gb(dup['bytes'])}) en {len(dup['grupos'])} grupos: se pueden juntar en subcarpetas Duplicados para revisarlos a mano.") diff --git a/organizador.py b/organizador.py index 86d7655..ddf8a58 100644 --- a/organizador.py +++ b/organizador.py @@ -675,7 +675,7 @@ def escanear(ruta): prog["estado"] = "listo" if fallos: prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})" - PLANES.pop(raiz, None) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco + olvidar_plan(raiz, recalcular=False) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco except Exception as ex: # noqa prog["estado"] = "error" prog["error"] = repr(ex) @@ -1975,6 +1975,9 @@ def actualizar_inventario(movidos): fila["es_proxy"], fila["es_basura"])) _recontar_carpetas(con, movidos) con.close() + # el plan que hubiera en memoria ya no describe la carpeta: se olvida y se vuelve a pensar solo + for k in {r for de, a in movidos for r in (_raiz_de(de), _raiz_de(a)) if r}: + olvidar_plan(k) def _recontar_carpetas(con, movidos): @@ -2155,6 +2158,8 @@ def borrar_vacias(raiz, rutas): if e.get("lote") == lote and e.get("op") == "rmdir": con.execute("DELETE FROM carpetas WHERE ruta = ?", (e["de"],)) con.close() + if borradas: + olvidar_plan(raiz) bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": borradas, "movimientos": borradas, "errores": errores}) return {"lote": lote, "borradas": borradas, "errores": errores} @@ -4428,9 +4433,53 @@ def copiar_respaldo(raiz, plan): # ========================================================================== PLANES = {} # raiz -> plan +PLAN_GEN = defaultdict(int) # raiz -> cuántas veces se ha olvidado su plan: un recálculo viejo no pisa uno nuevo +REPLAN = {} # raiz -> el hilo que está volviendo a pensar el plan después de mover algo PLANES_RESPALDO = {} +def olvidar_plan(raiz, recalcular=True): + """Después de mover algo, el plan calculado ya no describe la carpeta: se olvida y, salvo que se diga lo + contrario, se vuelve a pensar en segundo plano. Así Inicio dice lo que de verdad queda (o que no queda + nada) en vez de ofrecer otra vez lo que se acaba de hacer, o de pedir que se calcule. Medido el + 14-oct-2026 en el laboratorio: tras «Hacer las 5 de una vez», Inicio seguía diciendo «Ordenar por año y + mes · 5 cambios · Ver y aplicar» y «Plan listo: 7 de 9 movimientos marcados» con todo ya movido, porque + el plan solo se olvidaba al aplicar desde la pestaña «Qué va a pasar»; y tras «Deshacer», al revés: + «Lo seguro ya está hecho» con la carpeta otra vez en desorden.""" + k = _clave(raiz) + PLANES.pop(k, None) + PLAN_GEN[k] += 1 + if not recalcular or not raiz_cfg(k): + return + gen = PLAN_GEN[k] + + def _pensar(): + try: + plan = Plan(raiz_cfg(k)).correr() + if PLAN_GEN[k] == gen: # nadie movió nada más mientras tanto + PLANES[k] = plan + except Exception as ex: # noqa + print(f"no se pudo volver a pensar el plan de {k}: {ex}", file=sys.stderr) + finally: + if REPLAN.get(k) is threading.current_thread(): + REPLAN.pop(k, None) + + t = threading.Thread(target=_pensar, daemon=True) + REPLAN[k] = t + t.start() + + +def plan_en_marcha(raiz): + """Si el plan de esta carpeta se está volviendo a pensar, espera a que acabe y lo devuelve; si no, None. + Lo usan «Calcular» y la pasada automática para no recorrer la carpeta dos veces seguidas.""" + k = _clave(raiz) + t = REPLAN.get(k) + if t is None or t is threading.current_thread(): + return None + t.join() + return PLANES.get(k) + + def _clave(ruta): return os.path.normpath(ruta) @@ -5188,7 +5237,7 @@ def nueva_pieza(raiz, titulo, anio=None, mes=None, crear=True): con.execute("INSERT OR IGNORE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)", (d, raiz, os.path.basename(d))) con.close() bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": 1, "movimientos": len(creadas), "errores": []}) - PLANES.pop(raiz, None) + olvidar_plan(raiz) return {**out, "ok": True, "lote": lote, "creadas": len(creadas)} @@ -5522,11 +5571,11 @@ def automatico_una(raiz, limite): out["errores"] = (res.get("errores") or [])[:20] if asistente.ESTADO.get("error"): out["errores"].append(asistente.ESTADO["error"]) - despues = Plan(r).correr() + despues = plan_en_marcha(raiz) or Plan(r).correr() out["dudosos"] = sum(1 for f in despues["filas"] if not f["marcado"]) out["avisos"] = despues["avisos"][:20] out["estado"] = "con errores" if out["errores"] else "con dudas" if out["dudosos"] else "al dia" - PLANES.pop(raiz, None) + PLANES[despues["raiz"]] = despues # se queda: así Inicio dice por la mañana qué quedó, sin pedir que se calcule return out @@ -5668,6 +5717,34 @@ def automatico_por_comando(): return 1 +_LOTES_OTROS = ("stock-", "dup-", "vacias-", "entrada-", "optimizar", "tanda-", "pieza-", "descartados-", "colores-", "respaldo") + + +def _ultimo_orden(raiz): + """El último lote de orden (mover y reorganizar) aplicado sobre esta carpeta y no deshecho: cuándo y cuánto.""" + ent = leer_bitacora(3000) + deshechos = {e.get("de_lote") for e in ent if e.get("op") == "deshacer"} + for e in reversed(ent): + l = e.get("lote") or "" + if e.get("op") == "fin" and (e.get("raiz") or "").lower() == raiz.lower() and l and not l.startswith(_LOTES_OTROS) and l not in deshechos: + return {"lote": l, "ts": e.get("ts"), "movimientos": e.get("movimientos", 0), "errores": len(e.get("errores") or [])} + return None + + +def _cuando(ts): + """«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40».""" + if not ts: + return "" + t = time.localtime(ts) + hoy = time.localtime() + hora = time.strftime("%H:%M", t) + if t[:3] == hoy[:3]: + return f"hoy a las {hora}" + if t[:3] == time.localtime(time.time() - 86400)[:3]: + return f"ayer a las {hora}" + return f"el {t.tm_mday} de {R.MESES[t.tm_mon - 1]}" + (f" de {t.tm_year}" if t.tm_year != hoy.tm_year else "") + f" a las {hora}" + + def acciones(raiz): """Todo lo que se puede hacer AHORA MISMO en esta carpeta, en orden, cada cosa con su cifra. @@ -5730,8 +5807,12 @@ def acciones(raiz): detalle=f"Todo pasa a «{R.PAPELERA}». No se borra nada.", ir="plan", paso="limpiar") else: + # lo ya apartado se dice, para que se vea que el cambio se hizo (antes la ficha quedaba muda) + apartado = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? AND (" + + " OR ".join(["ruta LIKE ? ESCAPE '!'"] * len(R.PAPELERAS)) + ")", [raiz] + args_pap).fetchone() añadir(clave="limpiar", titulo="Basura y proxies", estado="hecho", - que="No queda nada suelto que apartar.", cifra="", detalle="") + que="No queda nada suelto que apartar.", cifra="", + detalle=f"{_plural(apartado['n'], 'archivo', 'archivos')} ya en «{R.PAPELERA}» ({_gb(apartado['b'])}): se borran cuando tú quieras." if apartado["n"] else "") # --- 2. carpetas que la app no reconoce --- voc = vocabulario(raiz) @@ -5754,6 +5835,8 @@ def acciones(raiz): # --- 3 y 4. ordenar (usa el plan solo si ya estaba calculado) --- plan = PLANES.get(raiz) + ultimo = _ultimo_orden(raiz) + hecho_det = f"Hecho {_cuando(ultimo['ts'])}: {_plural(ultimo['movimientos'], 'movimiento', 'movimientos')}." if ultimo else "" for clave, titulo, que in (("fechas", "Ordenar por año y mes", "Meses con nombre completo, grabaciones repartidas por mes y cámara, piezas numeradas."), ("resto", "Ordenar lo demás", @@ -5766,8 +5849,8 @@ def acciones(raiz): # Si contara como pendiente, la carpeta no llegaría nunca a estar «al día». añadir(clave=clave, titulo=titulo, boton="Ver", que=que, estado="espera", cifra=_plural(len(fs), "dudoso", "dudosos"), - detalle="Lo seguro ya está hecho. Queda lo que la app no se atreve a mover sola: " - "míralo y marca lo que quieras.", ir="plan", paso=clave) + detalle=(hecho_det + " " if hecho_det else "Lo seguro ya está hecho. ") + + "Queda lo que la app no se atreve a mover sola: míralo y marca lo que quieras.", ir="plan", paso=clave) elif fs: añadir(clave=clave, titulo=titulo, boton="Ver y aplicar", que=que, cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}", @@ -5775,12 +5858,16 @@ def acciones(raiz): if len(marc) < len(fs) else "Todo se mueve dentro de la misma carpeta."), ir="plan", paso=clave) else: - añadir(clave=clave, titulo=titulo, estado="hecho", que="Nada que mover.", cifra="", detalle="") + añadir(clave=clave, titulo=titulo, estado="hecho", que="Nada que mover.", cifra="", detalle=hecho_det) + elif REPLAN.get(raiz): + # se acaba de mover algo y el plan se está volviendo a pensar: en un momento se sabe qué queda + añadir(clave=clave, titulo=titulo, que=que, cifra="", estado="espera", calculando=True, + detalle=(hecho_det + " " if hecho_det else "") + "Comprobando qué queda…", ir="plan", paso=clave) else: # 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) + detalle=(hecho_det + " " if hecho_det else "") + "Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave) # --- 5. carpetas que quedaron vacías --- vac = carpetas_vacias(raiz) @@ -5896,7 +5983,8 @@ def acciones(raiz): auto = [a["clave"] for a in out if a["estado"] == "toca" and a["clave"] in ("limpiar", "fechas", "resto", "stock", "repetidos")] if (esc["reglas"] or 0) < R.VERSION_REGLAS: auto = [] # con un inventario viejo no se hace nada «de una vez»: primero se relee - return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc), + return {"raiz": raiz, "cliente": r["cliente"], "acciones": out, "escaneo": dict(esc), "ultimo": ultimo, + "calculando": REPLAN.get(raiz) is not None, "automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None} @@ -6060,7 +6148,7 @@ class Manejador(BaseHTTPRequestHandler): r = raiz_cfg(q["raiz"]) if not r: return self._json({"error": "raíz no configurada"}, 400) - plan = Plan(r).correr() + plan = plan_en_marcha(r["ruta"]) or Plan(r).correr() PLANES[plan["raiz"]] = plan return self._json(plan) if ruta == "/api/bitacora": @@ -6277,6 +6365,10 @@ class Manejador(BaseHTTPRequestHandler): raiz = os.path.normpath(d["raiz"]) plan = PLANES.get(raiz) if not plan: + # el plan se olvidó porque la carpeta cambió (se movió algo, se volvió a escanear): los + # números de fila que trae la interfaz ya no dicen nada; se pide calcularlo otra vez + if PLAN_GEN.get(raiz): + return self._json({"error": "la carpeta cambió desde que se calculó el plan: hay que volver a calcular «Qué va a pasar»", "recalcular": True}, 409) return self._json({"error": "primero genera el plan"}, 400) ids = set(d.get("ids", [])) filas = [f for f in plan["filas"] if f["id"] in ids] @@ -6289,7 +6381,8 @@ class Manejador(BaseHTTPRequestHandler): return self._json({"error": f"el destino «{ed}» queda fuera de la carpeta del cliente"}, 400) f["destino"] = nuevo res = aplicar(raiz, filas) - PLANES.pop(raiz, None) + if not res.get("movimientos"): + olvidar_plan(raiz) # si algo se movió, ya lo olvidó el inventario return self._json(res) if ruta == "/api/deshacer": return self._json(deshacer(d["lote"])) diff --git a/prueba_funcional.py b/prueba_funcional.py index 400edd7..8867403 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -228,6 +228,13 @@ def p_plan(): LOTES = {} +def acciones_quietas(): + """La lista de Inicio cuando el programa ha terminado de volver a pensar el plan tras mover algo.""" + assert esperar(lambda: not get('/api/carpeta/acciones' + q(raiz=RAIZ))['calculando'], 60), 'el plan no se volvió a pensar' + a = get('/api/carpeta/acciones' + q(raiz=RAIZ)) + return a, {x['clave']: x for x in a['acciones']} + + def p_aplicar(): antes = arbol() c, r = post('/api/aplicar', {'raiz': RAIZ, 'ids': [f['id'] for f in PLAN['filas'] if f['marcado']]}) @@ -241,7 +248,17 @@ def p_aplicar(): 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»; el avance acabó en %d de %d' % (r['movimientos'], av['unidades'], av['unidades_total']) + # Inicio, sin que nadie vuelva a calcular: lo hecho sale como hecho (y cuándo), no como pendiente + a, por = acciones_quietas() + assert por['resto']['estado'] == 'hecho' and por['resto']['detalle'].startswith('Hecho hoy'), por['resto'] + assert por['fechas']['estado'] != 'toca', por['fechas'] + assert por['limpiar']['estado'] == 'hecho' and 'ya en' in por['limpiar']['detalle'], por['limpiar'] + assert a['ultimo'] and a['ultimo']['lote'] == r['lote'], a['ultimo'] + e = get('/api/carpeta/estado' + q(raiz=RAIZ)) + assert e['plan'] and e['plan']['marcadas'] == 0, e['plan'] + fr = e['diagnostico']['frases'] + assert any('ya apartados' in f for f in fr) and not any('de basura de sistema' in f for f in fr), fr + return '%d movimientos, sin errores, «10- octubre» pasó a «10- Octubre»; el avance acabó en %d de %d; Inicio lo da por hecho' % (r['movimientos'], av['unidades'], av['unidades_total']) def p_deshacer(): @@ -249,7 +266,11 @@ def p_deshacer(): assert c == 200 and not r['errores'], r assert '10- octubre' in os.listdir(os.path.join(RAIZ, '2026')), 'deshacer no devolvió las minúsculas: %s' % os.listdir(os.path.join(RAIZ, '2026')) assert os.path.isfile(os.path.join(RAIZ, '2026', '03- Marzo', 'Grabacion', 'Thumbs.db')), 'la basura no volvió' - return '%d movimientos deshechos' % r['deshechos'] + # y en Inicio vuelve a estar todo por hacer (antes se quedaba con «lo seguro ya está hecho») + a, por = acciones_quietas() + assert all(por[k]['estado'] == 'toca' for k in ('limpiar', 'fechas', 'resto')), {k: por[k]['estado'] for k in ('limpiar', 'fechas', 'resto')} + assert not a['ultimo'], a['ultimo'] + return '%d movimientos deshechos, e Inicio vuelve a ofrecerlo todo' % r['deshechos'] def p_reaplicar(): @@ -448,7 +469,10 @@ def p_asistente(): assert esperar(lambda: not get('/api/estado')['asistente'].get('activo'), 120) a = get('/api/estado')['asistente'] assert not a.get('error'), a.get('error') - return 'pasos + «hacer las N de una vez» terminan sin error' + # después, Inicio no vuelve a ofrecer lo que acaba de hacer + _, por = acciones_quietas() + assert por['fechas']['estado'] != 'toca' and por['resto']['estado'] != 'toca', {k: por[k] for k in ('fechas', 'resto')} + return 'pasos + «hacer las N de una vez» terminan sin error, e Inicio no lo vuelve a ofrecer' def p_pieza(): diff --git a/static/index.html b/static/index.html index 9eddae4..be1d0b5 100644 --- a/static/index.html +++ b/static/index.html @@ -1137,8 +1137,8 @@ function desconectar(clave){ $('#btnDesconectar').onclick = () => desconectar(raizActual()); // ---------------- carpeta ---------------- -const COLOR = {camara_raw:'#6b2f2a', stock_viejo:'#b8923a', stock_reciente:'#d9c58c', camara_video:'#3d5a73', exportados:'#7f8a94', proyectos:'#a9b0b8', basura:'#d3d6da', proxies:'#c4c9ce', audio:'#4f6b5a', celular:'#7a5c8a', fotos:'#8a6f4f', video_otro:'#94a3ad', imagenes:'#b5bec6', documentos:'#cfd5da', otros:'#e1e5e8'}; -const NOMBRE_CORTO = {camara_raw:'BRAW', stock_viejo:'stock viejo', stock_reciente:'stock reciente', camara_video:'cámara MP4', exportados:'exportados', proyectos:'proyectos', basura:'basura', proxies:'proxies', audio:'audio', celular:'celular', fotos:'fotos', video_otro:'otros videos', imagenes:'imágenes', documentos:'documentos', otros:'otros'}; +const COLOR = {camara_raw:'#6b2f2a', stock_viejo:'#b8923a', stock_reciente:'#d9c58c', camara_video:'#3d5a73', exportados:'#7f8a94', proyectos:'#a9b0b8', basura:'#d3d6da', proxies:'#c4c9ce', audio:'#4f6b5a', celular:'#7a5c8a', fotos:'#8a6f4f', video_otro:'#94a3ad', imagenes:'#b5bec6', documentos:'#cfd5da', otros:'#e1e5e8', apartado:'#cfd4d8'}; +const NOMBRE_CORTO = {camara_raw:'BRAW', stock_viejo:'stock viejo', stock_reciente:'stock reciente', camara_video:'cámara MP4', exportados:'exportados', proyectos:'proyectos', basura:'basura', proxies:'proxies', audio:'audio', celular:'celular', fotos:'fotos', video_otro:'otros videos', imagenes:'imágenes', documentos:'documentos', otros:'otros', apartado:'ya apartado'}; function pintarPortada(){ // las carpetas que el programa ya conoce, para entrar en una con un clic; con la última vez que se // escaneó cada una. Es el «historial» de la portada: nada se abre solo. @@ -1207,10 +1207,12 @@ async function pintarAcciones(){ const corriendo = asis && asis.activo; const cab = corriendo ? `
Trabajando en esta carpeta${esc(NOMBRE_PASO[asis.paso] || asis.paso || '')} · ${asis.hechas || 0} de ${asis.total || 0}
` - : `
${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día'}${auto.length > 1 ? '' : ''}
`; + : `
${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día' + (d.ultimo ? ` · último cambio ${esc(fechaCorta(d.ultimo.ts))}` : '')}${auto.length > 1 ? '' : ''}
`; $('#acciones').innerHTML = cab + - ACCIONES.map((a, i) => `
${a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}
${esc(a.titulo)}
${esc(a.que)}
${a.detalle ? `
${esc(a.detalle)}
` : ''}
${a.cifra ? `${esc(a.cifra)}` : ''}${a.boton ? `` : ''}
`).join(''); + ACCIONES.map((a, i) => `
${a.calculando ? '' : a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}
${esc(a.titulo)}
${esc(a.que)}
${a.detalle ? `
${esc(a.detalle)}
` : ''}
${a.cifra ? `${esc(a.cifra)}` : ''}${a.boton ? `` : ''}
`).join(''); $$('#acciones [data-a]').forEach(b => b.onclick = () => { const a = ACCIONES[+b.dataset.a]; (IR_ACCION[a.ir] || (() => {}))(a.paso); }); + // se acaba de mover algo y el programa está comprobando qué queda: se vuelve a preguntar en un momento + if (d.calculando) setTimeout(() => { if (raizActual() === raiz && vistaActual() === 'carpeta') cargarCarpeta().catch(() => {}); }, 1500); const bt = $('#btnHacerTodo'); if (bt) bt.onclick = () => modal('Hacer todo lo seguro de una vez', `

La app va a hacer seguidas las ${auto.length} cosas de la lista que no necesitan que elijas nada, en orden y sin preguntar.

@@ -1250,7 +1252,9 @@ function pasosDe(C, r){ const lotes = (C.lotes || []).filter(l => !l.lote.startsWith('stock-') && !l.lote.startsWith('optimizar') && !l.lote.startsWith('descartados-') && !l.lote.startsWith('tanda-')); const asis = C.asistente && C.asistente.activo; P.push(!esc ? {e:'bloq', t:'Ordenar', d:'Después de escanear'} : asis ? {e:'curso', t:'Ordenar', d: (NOMBRE_PASO[C.asistente.paso] || '') + '…', corto: 'en marcha'} - : C.plan ? {e:'listo', t:'Ordenar', d:`Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`, corto: `${C.plan.marcadas} mov.`} + : C.plan && C.plan.marcadas ? {e:'listo', t:'Ordenar', d:`Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`, corto: `${C.plan.marcadas} mov.`} + // un plan sin nada marcado es una carpeta en orden (quizá con dudosos): hecho, no «listo para aplicar» + : C.plan ? {e:'hecho', t:'Ordenar', d: (lotes.length ? `Último cambio ${fecha(lotes[0].ts)} · ${lotes[0].movimientos} mov.` : 'Nada que mover') + (C.plan.filas ? ` · ${C.plan.filas} dudoso${C.plan.filas > 1 ? 's' : ''} sin marcar` : ''), corto: lotes.length ? fechaCorta(lotes[0].ts) : 'ok'} : lotes.length ? {e:'hecho', t:'Ordenar', d:`Último cambio ${fecha(lotes[0].ts)} · ${lotes[0].movimientos} mov.`, corto: fechaCorta(lotes[0].ts)} : {e:'pend', t:'Ordenar', d:'Calcula qué va a pasar y aplica', corto: ''}); const cv = C.convertir || {}; const tr = cv.trabajos || {}; const n = k => (tr[k] || {}).n || 0; const activos = n('pendiente') + n('corriendo'), listos = n('listo'), verif = n('verificado'), reemp = n('reemplazado'); @@ -1281,7 +1285,7 @@ function pintarSiguiente(P){ else if (P[0].e === 'pend') { s.t = 'Empieza por escanear'; s.p = 'La app lee los nombres, tamaños y fechas de todos los archivos. No mueve nada. En una carpeta grande del NAS tarda unos minutos.'; b('Escanear ahora', () => $('#btnEscanear').click(), true); } else if (P[2].e === 'curso') { s.t = 'El asistente está trabajando'; s.p = 'Puedes seguir el avance en la barra de abajo.'; } else if (P[1].e === 'listo' && !C.plan && P[2].e !== 'hecho') { const n = C.sin_nombre_n; s.t = `Antes de ordenar: nombra ${n} carpeta${n > 1 ? 's' : ''}`; s.p = `La app no sabe qué son ${C.sin_nombre.slice(0, 4).map(x => '«' + x.ejemplo + '»').join(', ')}${n > 4 ? ' y otras' : ''}. Si le dices qué es cada una, las ordena bien y se lo aprende para todos los discos. Si prefieres, sáltalo: quedarán como «dudoso» y no se moverán.`; b('Nombrar las carpetas', () => { irA('revisar'); subRev('nombrar'); }, true); b('Saltar y calcular', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }); } - else if (C.plan) { 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 (C.plan && C.plan.marcadas) { s.t = `Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`; s.p = `Todo se mueve dentro de la misma carpeta y nada se borra. ${C.plan.baja ? C.plan.baja + ' son dudosos y están sin marcar. ' : ''}Revisa la lista y aplica; se puede deshacer desde Historial.`; b('Revisar y aplicar', () => { irA('revisar'); subRev('plan'); }, true); } else if (P[2].e === 'pend') { s.t = 'Siguiente: ordenar la carpeta'; s.p = 'Calcula qué va a pasar (basura y proxies a _Para borrar, grabaciones por mes y cámara, piezas numeradas) y aplícalo con un clic. O deja que el asistente te pregunte paso a paso.'; b('Calcular qué va a pasar', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }, true); } else if (P[3].e === 'listo') { const d = P[3].d; s.t = 'Siguiente: ' + (d.includes('verificar') ? 'verificar los clips convertidos' : d.includes('reemplazar') ? 'reemplazar los originales' : 'retomar la conversión'); s.p = d.includes('verificar') ? 'Verificar comprueba códec, 10 bits y duración. Después puedes reemplazar los originales (quedan en _Para borrar/originales).' : d.includes('reemplazar') ? 'Los originales se apartan a _Para borrar/originales y el MP4 10 bits queda en su lugar. Nada se borra.' : 'Hay clips en cola con la conversión parada.'; b('Ir a Convertir', () => irA('convertir'), true); } else if (P[3].e === 'curso') { s.t = 'Convirtiendo clips'; s.p = 'Sigue en segundo plano. El avance está en la barra de abajo; cuando termine, toca reemplazar.'; b('Ver la cola', () => irA('convertir')); } @@ -1593,7 +1597,13 @@ function aplicarFilas(m, btn, titulo){ // mientras el servidor mueve, se le pregunta por dónde va (archivo a archivo); lo de una tanda anterior no vale const reloj = setInterval(async () => { try { const a = await api('/api/aplicando'); if (a.inicio && a.inicio * 1000 >= t0 - 2000 && !a.fin) pintarAvance({activo: true, ...a, total: m.length}); } catch (e) {} }, 400); let res; - try { res = await api('/api/aplicar', {raiz, ids: m.map(r => r.id), destinos}); } catch (e) { clearInterval(reloj); $('#planAvance').innerHTML = ''; throw e; } + try { res = await api('/api/aplicar', {raiz, ids: m.map(r => r.id), destinos}); } + catch (e) { + clearInterval(reloj); $('#planAvance').innerHTML = ''; + // la carpeta cambió desde que se calculó (se apartó stock, se deshizo algo…): se calcula otra vez y ya + if (e.datos && e.datos.recalcular) { aviso('La carpeta cambió desde que se calculó el plan. Se vuelve a calcular; revísalo y aplica.', 'bad'); PLAN = null; await calcularPlan(); return; } + throw e; + } clearInterval(reloj); PLAN = null; MAPA = null; pintarAvance({activo: false, res, seg: (Date.now() - t0) / 1000, pedidas: m.length, bytes});