Plan por pasos en frases, sugerencias en Por nombrar, stock por antigüedad, respaldo por lotes, arrastre con ruta real, barra de tareas

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-15 20:23:14 -05:00
padre b1f65f94c2
commit 0895846f34
Se han modificado 2 ficheros con 279 adiciones y 75 borrados

Ver fichero

@@ -525,6 +525,10 @@ class Plan:
"archivos": [a[0] for a in archivos] if archivos else None,
"marcado": (confianza in ("alta", "media")) if marcado is None else marcado,
"cosmetico": marcado is False and accion == "renombrar",
# en qué paso del recorrido se aplica: 1 limpiar (basura/proxies), 2 por fecha (años, meses, grabaciones,
# piezas), 3 el resto (exportados, proyectos, marca, música, dudosos)
"paso": "limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else
"fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto",
})
def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
@@ -1081,10 +1085,65 @@ def vocabulario(raiz):
t["bytes"] += c["bytes"] or 0
if len(t["rutas"]) < 4:
t["rutas"].append(rel)
if tokens:
con = db()
for t in tokens.values():
if not t["cubeta"]:
_sugerir(con, raiz, t)
con.close()
out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
return out
_ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición / diseño", "audio": "audio",
"imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"}
def _sugerir(con, raiz, t):
"""Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre.
Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible)."""
import difflib
conteo = defaultdict(lambda: [0, 0])
total = [0, 0]
for rel in t["rutas"][:3]:
for f in archivos_bajo(con, os.path.join(raiz, rel)):
if f["es_basura"] or f["es_proxy"]:
continue
rol = f["rol"]
k = ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
"proyecto" if rol in ("proyecto", "diseno") else
"audio" if rol in ("audio",) else
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
"documento" if rol == "documento" else
"video" if rol in ("video", "celular_video") else "otro")
conteo[k][0] += 1
conteo[k][1] += f["bytes"]
total[0] += 1
total[1] += f["bytes"]
orden = sorted(conteo.items(), key=lambda kv: -kv[1][1])
t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas"
sug, conf, por_que = "", "", ""
# a) por el nombre: se parece a una palabra del diccionario
dicc = DICC["carpetas"]
cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82)
if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR"):
sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»"
# b) por el contenido (manda si es contundente)
if total[0] >= 3 and orden:
k, (n, b) = orden[0]
frac = b / total[1] if total[1] else n / total[0]
if frac >= 0.7:
por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS",
"documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k)
if por_cont:
if sug and sug != por_cont:
por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}"
conf = "baja"
else:
sug, conf, por_que = por_cont, ("alta" if frac >= 0.9 and n >= 3 else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que
# ==========================================================================
# Mapa
# ==========================================================================
@@ -1359,7 +1418,7 @@ def encolar(rutas, perfil, chroma, color, raiz):
return ids
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": ""}
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0}
def set_trabajo(id_, **kw):
@@ -1537,6 +1596,7 @@ def bucle_cola():
except Exception as ex: # noqa
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
_verificar_listos([t["id"]])
COLA["hechos"] += 1
elif lote_resolve:
COLA["actual"] = lote_resolve[0]["id"]
try:
@@ -1545,6 +1605,7 @@ def bucle_cola():
for r in lote_resolve:
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
_verificar_listos([r["id"] for r in lote_resolve])
COLA["hechos"] += len(lote_resolve)
else:
break
COLA["actual"] = None
@@ -1590,6 +1651,8 @@ def iniciar_cola():
recuperar_huerfanas()
COLA["activa"] = True
COLA["cancelar"] = False
COLA["hechos"] = 0
COLA["inicio"] = time.time()
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
COLA["hilo"].start()
return True
@@ -1905,7 +1968,7 @@ def apartar_duplicados(raiz, grupos=None):
RESPALDO = {"estado": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, "actual": "", "errores": [], "destino": "", "cancelar": False, "inicio": 0, "copiados": 0, "saltados": 0}
def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True):
def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True, meses=0):
con = db()
rc = raiz_cfg(raiz)
cliente = rc["cliente"] if rc else os.path.basename(raiz.rstrip("\\/")) or "Raiz"
@@ -1920,6 +1983,8 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru
marca_a = os.sep + "_Archivo" + os.sep
items, vistos = [], set()
omit_raw, omit_stock_b, omit_stock_n, convertidos = [], 0, 0, 0
tope = limite_meses(meses) if int(meses or 0) > 0 else None # solo lo anterior a este (año, mes)
omit_reciente_n, omit_reciente_b = 0, 0
def agregar(ruta, rol, ext, bytes_, mtime):
k = os.path.normpath(ruta).lower()
@@ -1938,6 +2003,10 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru
omit_stock_b += f["bytes"]
omit_stock_n += 1
continue
if tope and not es_viejo(f, tope):
omit_reciente_n += 1
omit_reciente_b += f["bytes"]
continue
k = os.path.normpath(f["ruta"]).lower()
if k in conv:
sal = conv[k]
@@ -1979,6 +2048,7 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru
"por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])],
"convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]},
"stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b},
"reciente_omitido": {"n": omit_reciente_n, "bytes": omit_reciente_b, "meses": int(meses or 0)},
"muestra": [{"de": i["rel"], "a": os.path.relpath(i["destino"], destino)} for i in items[:25]],
"items": items,
}
@@ -2128,7 +2198,14 @@ def estado_general():
con = db()
esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()}
cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()}
actual = None
if COLA["actual"]:
t = con.execute("SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone()
if t:
actual = dict(t)
con.close()
# «clip 3 de 12»: los hechos en esta tanda + el que va + los que esperan
total_tanda = COLA["hechos"] + (1 if actual else 0) + cola_n.get("pendiente", 0)
esc_ci = {k.lower(): v for k, v in esc.items()}
raices = []
for r in CONFIG["raices"]:
@@ -2136,7 +2213,8 @@ def estado_general():
raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)})
return {"config": CONFIG, "raices": raices, "herramientas": herramientas(),
"version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO,
"cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "n": cola_n},
"cola": {"activa": COLA["activa"], "actual": actual, "mensaje": COLA["mensaje"], "n": cola_n,
"hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]},
"aplicando": APLICANDO, "asistente": asistente.ESTADO,
"respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])},
"cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "plantillas": PLANTILLAS}
@@ -2253,6 +2331,9 @@ class Manejador(BaseHTTPRequestHandler):
return
if ruta == "/api/estado":
return self._json(estado_general())
if ruta == "/api/soltado":
r = soltado(q.get("nombre", ""))
return self._json({"ruta": r, "es_archivo": bool(r) and os.path.isfile(r)})
if ruta == "/api/carpeta/estado":
return self._json(estado_carpeta(q["raiz"]))
if ruta == "/api/mapa":
@@ -2324,7 +2405,7 @@ class Manejador(BaseHTTPRequestHandler):
destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "")
if not destino:
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400)
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1")
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0))
PLANES_RESPALDO[raiz] = plan
CONFIG.setdefault("respaldo", {})["destino"] = destino
guardar_json(CONFIG_PATH, CONFIG)
@@ -2724,6 +2805,31 @@ def _asegurar_consola():
pass
SOLTADOS = {"ts": 0, "rutas": []}
def _al_soltar(e):
try:
rutas = [f.get("pywebviewFullPath") for f in (e.get("dataTransfer") or {}).get("files", []) if f.get("pywebviewFullPath")]
except Exception: # noqa
rutas = []
if rutas:
SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas])
def soltado(nombre, espera=2.5):
"""Ruta completa de lo que se acaba de soltar en la ventana (llega por el puente WebView2). Espera un poco
porque el drop del navegador y el aviso del puente no llegan en el mismo orden."""
fin = time.time() + espera
while time.time() < fin:
if time.time() - SOLTADOS["ts"] < 8:
for r in SOLTADOS["rutas"]:
if not nombre or os.path.basename(r.rstrip("\\/")).lower() == nombre.lower():
return r
time.sleep(0.1)
return None
def _ventana(url):
"""Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador."""
try:
@@ -2733,6 +2839,15 @@ def _ventana(url):
return False
try:
v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff")
def _al_cargar():
# WebView2 entrega la ruta completa de lo que se suelta solo por este puente (el navegador no la da):
# se guarda y la interfaz la pide en /api/soltado en cuanto ve el drop.
try:
v.dom.document.events.drop += _al_soltar
except Exception as ex: # noqa
print("sin puente de arrastre:", ex)
v.events.loaded += _al_cargar
webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana"))
return True
except Exception as ex: # noqa