Inicio dice lo que ya se hizo: el plan se olvida al mover y se vuelve a pensar solo
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 <noreply@anthropic.com>
Este commit está contenido en:
117
organizador.py
117
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"]))
|
||||
|
||||
Referencia en una nueva incidencia
Block a user