2.3.0: proyectos por programa, carpetas vacías y mapa

Tres cosas para la reorganización, pedidas juntas.

Proyectos por programa: la plantilla pasa de 01_Proyectos/{nombre} a
01_Proyectos/{app}/{nombre}, con {app} = el programa que abre el archivo
(R.APPS_PROYECTO, R.app_proyecto). Lo que ya viene en su propia carpeta se
queda con el nombre de esa carpeta; lo que se separa es lo suelto. Las
plantillas guardadas se migran solas si nadie las tocó. Entran extensiones
que caían en «otro»: .sesx, .c4d, .blend, .motn, .aepx, .aet, .prel, .idml,
.psdt, .afpub.

Carpetas vacías: carpetas_vacias() las encuentra desde el inventario y las
comprueba en el disco; borrar_vacias() las quita. Es lo único que la app
borra, y solo porque no hay nada dentro: cada una queda en la bitácora como
rmdir y Deshacer las vuelve a crear con su nombre y en el inventario. No
entra en «Hacer las N de una vez». 29 huecos en David Reyes, 40 en N:.

Mapa: pestaña nueva con el árbol que producen las plantillas de verdad
(mapa_estructura, GET /api/estructura). Al tocar una carpeta dice qué guarda
—derivado de ROL_A_CUBETA y ROL_QUE—, cuánto hay hoy, y deja cambiar el
destino ahí mismo. POST /api/plantillas pasa a fusionar cubeta a cubeta:
guardar una sola plantilla borraba las otras diecinueve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 22:34:12 -05:00
padre 2930320690
commit ba186d4a26
Se han modificado 6 ficheros con 608 adiciones y 29 borrados

Ver fichero

@@ -34,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "2.2.1"
VERSION = "2.3.0"
CONGELADO = getattr(sys, "frozen", False)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
@@ -258,6 +258,9 @@ for t, d in R.PLANTILLAS.items():
# mientras toda la interfaz hablaba de la nueva. Se migra una sola vez.
if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"):
PLANTILLAS[t][b] = p
# los proyectos pasaron a separarse por programa: «01_Proyectos/{nombre}» -> «01_Proyectos/{app}/{nombre}»
if b == "PROYECTOS" and PLANTILLAS[t][b] == p.replace("{app}/", ""):
PLANTILLAS[t][b] = p
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
@@ -622,6 +625,7 @@ def destino_plantilla(tipo, cubeta, **kw):
"sub": kw.get("sub", ""),
"rel": kw.get("rel", ""),
"fuente": kw.get("fuente", ""),
"app": kw.get("app", ""),
}
# un valor vacío se lleva consigo el separador que lo precedía: "{anio}-{mes}_Grabacion" sin mes -> "2026_Grabacion"
vacio = "\x00"
@@ -1093,15 +1097,17 @@ class Plan:
conf = "media" if nivel == "mes" else "baja"
motivo = "video suelto: parece un exportado" + (" · mes por fecha" if not mes else "")
if cub == "PROYECTOS":
sub = {"diseno": "Diseño", "proyecto": "Edicion"}.get(rol, "")
motivo = "proyecto de edición / diseño suelto"
# el programa manda sobre el rol: un .aep y un .psd sueltos en la misma carpeta van cada uno
# a la suya, que es justo lo que cuesta encontrar cuando están mezclados
sub = R.app_proyecto(fila["ext"]) or ("Diseño" if rol == "diseno" else "Edicion")
motivo = f"proyecto de {sub} suelto" if R.app_proyecto(fila["ext"]) else "proyecto de edición / diseño suelto"
conf = "alta"
if cub == "MUSICA" and rol == "audio":
conf = "baja"
if cub == "REVISAR":
destino = "_Revisar"
elif cub == "PROYECTOS":
destino = destino_plantilla(self.tipo, "PROYECTOS", nombre=sub)
destino = destino_plantilla(self.tipo, "PROYECTOS", app=sub)
else:
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, sub=sub, nombre="")
self.suelto(fila, cub, destino, motivo, conf)
@@ -1335,16 +1341,111 @@ def quitar_vacias(ruta, tope):
ruta = os.path.dirname(ruta)
def _sin_archivos(ruta):
"""True si debajo de `ruta` no hay un solo archivo ni ningún enlace: nada que perder al borrarla.
Un enlace o una unión de Windows cuentan como contenido aunque no sean archivos: apuntan a otro
árbol y borrarlos sí perdería algo."""
try:
with os.scandir(ruta) as it:
for e in it:
if _es_enlace(e) or not e.is_dir(follow_symlinks=False):
return False
if not _sin_archivos(e.path):
return False
except OSError:
return False # no se puede leer: se da por ocupada
return True
def carpetas_vacias(raiz):
"""Carpetas sin un solo archivo dentro, en todo el árbol.
Ordenar deja huecos: repartida «Grabacion» por mes y por cámara, la carpeta original se queda sin
nada. `quitar_vacias` solo limpia el camino de lo que se acaba de mover, así que lo que ya estaba
vacío antes se queda ahí para siempre.
Los candidatos salen del inventario (instantáneo: una carpeta está vacía si ninguna ruta de
`archivos` cuelga de ella) y cada uno se comprueba en el disco antes de enseñarlo, porque el
inventario puede tener días. Solo se devuelve la de más arriba de cada rama: si «2026\03- Marzo»
está vacía entera, no hace falta enumerar sus seis subcarpetas."""
raiz = _clave(raiz)
con = db()
carpetas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
con_archivos = set()
for r in con.execute("SELECT DISTINCT dir FROM archivos WHERE raiz = ?", (raiz,)).fetchall():
d = os.path.normpath(r["dir"])
while d and d.lower().startswith(raiz.lower()): # el directorio y todos sus padres tienen algo
if d.lower() in con_archivos:
break
con_archivos.add(d.lower())
padre = os.path.dirname(d)
if padre == d:
break
d = padre
con.close()
vacias = sorted({os.path.normpath(c) for c in carpetas
if os.path.normpath(c).lower() not in con_archivos
and os.path.normpath(c).lower() != raiz.lower()}, key=lambda x: (len(x), x.lower()))
out, tomadas = [], []
for c in vacias:
if any(c.lower().startswith(t + os.sep) for t in tomadas):
continue # ya cuelga de otra que se va entera
if not os.path.isdir(c) or not _sin_archivos(c):
continue # ya no existe, o alguien dejó algo dentro
dentro = sum(len(d) for _, d, _ in os.walk(c))
out.append({"ruta": c, "rel": os.path.relpath(c, raiz), "subcarpetas": dentro})
tomadas.append(c.lower())
return {"raiz": raiz, "carpetas": out, "n": len(out)}
def borrar_vacias(raiz, rutas):
"""Borra carpetas vacías. Es lo único que la app borra, y solo porque no hay nada dentro que
perder: cada carpeta queda apuntada en la bitácora y «Deshacer» las vuelve a crear."""
lote = "vacias-" + time.strftime("%Y%m%d-%H%M%S")
raiz = _clave(raiz)
borradas, errores = 0, []
for r in sorted({os.path.normpath(x) for x in rutas}, key=lambda x: -len(x)):
if not dentro_de(raiz, r) or r.lower() == raiz.lower():
errores.append(f"{r}: está fuera de la carpeta del cliente, no se toca")
continue
if not os.path.isdir(r):
continue # ya no está: nada que hacer
if not _sin_archivos(r):
errores.append(f"{os.path.basename(r)}: ya no está vacía, se deja como está")
continue
try:
for base, _, _ in sorted(os.walk(r), key=lambda x: -len(x[0])):
os.rmdir(base)
bitacora({"lote": lote, "op": "rmdir", "de": base})
borradas += 1
except OSError as ex:
errores.append(f"{os.path.basename(r)}: {ex}")
if borradas:
con = db()
with con:
for e in leer_bitacora(100000):
if e.get("lote") == lote and e.get("op") == "rmdir":
con.execute("DELETE FROM carpetas WHERE ruta = ?", (e["de"],))
con.close()
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": borradas, "movimientos": borradas, "errores": errores})
return {"lote": lote, "borradas": borradas, "errores": errores}
def deshacer(lote):
todas = leer_bitacora(100000)
entradas = [e for e in todas if e.get("lote") == lote and e.get("op") in ("mover", "rmdir")]
fin = next((e for e in todas if e.get("lote") == lote and e.get("op") == "fin"), {})
raiz = fin.get("raiz") or ""
deshechos, errores, movidos = 0, [], []
deshechos, errores, movidos, recreadas = 0, [], [], []
for e in reversed(entradas):
try:
if e["op"] == "rmdir":
os.makedirs(e["de"], exist_ok=True)
if lote.startswith("vacias-"):
# aquí la carpeta ERA el cambio, no el rastro de uno: se cuenta y vuelve al inventario
recreadas.append(e["de"])
deshechos += 1
else:
if not os.path.exists(e["a"]):
errores.append(f"{os.path.basename(e['a'])}: ya no está donde la app lo dejó; no se devuelve")
@@ -1363,6 +1464,13 @@ def deshacer(lote):
except OSError as ex:
errores.append(f"{e.get('a')}: {ex}")
actualizar_inventario(movidos)
if recreadas and raiz:
con = db()
with con:
for d in recreadas:
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,0,0,NULL,NULL,0,0,0,0)",
(d, raiz, os.path.basename(d)))
con.close()
bitacora({"lote": lote + "-deshecho", "op": "deshacer", "de_lote": lote, "movimientos": deshechos, "errores": errores})
return {"deshechos": deshechos, "errores": errores}
@@ -2747,6 +2855,112 @@ def _plural(n, uno, varios):
return f"{n:,}".replace(",", ".") + " " + (uno if n == 1 else varios)
# Qué enseñar en lugar de cada variable de una plantilla, y cómo se llama en llano.
EJEMPLO_VAR = {
"anio": ("2026", "el año del material"),
"mes": ("09- Septiembre", "el mes del material"),
"nombre": ("Nombre de la pieza", "el nombre de la pieza o de la carpeta"),
"fuente": ("URSA_BRAW", "la cámara de la que salió"),
"app": ("Premiere", "el programa que abre el proyecto"),
}
# Estas dos casi siempre están vacías (el archivo cae directo en la carpeta de arriba), así que no son
# un nivel más del árbol: son una nota en el nivel que las contiene.
VAR_NOTA = {
"sub": "y si el material venía dentro de una subcarpeta, se conserva",
"rel": "se copia la ruta tal cual venía, para poder devolverla a su sitio",
}
def mapa_estructura(tipo, raiz=None):
"""El árbol de carpetas que produce una plantilla, con qué cae en cada una y cuánto hay ya.
Es la respuesta a «¿dónde va a acabar mi material?» antes de mover nada. Se arma con las plantillas
de verdad, así que lo que enseña es exactamente lo que la app va a hacer, y si se edita una
plantilla el árbol cambia con ella."""
tipo = tipo if tipo in PLANTILLAS else "contenido"
pl = PLANTILLAS[tipo]
# qué hay hoy en la carpeta, repartido por cubeta según el rol de cada archivo
hay, apps, cliente = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0]), ""
if raiz:
raiz = _clave(raiz)
r = raiz_cfg(raiz)
cliente = r["cliente"] if r else ""
con = db()
for f in con.execute("SELECT rol, ext, COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos "
"WHERE raiz = ? GROUP BY rol, ext", (raiz,)).fetchall():
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
hay[cub][0] += f["n"]
hay[cub][1] += f["b"]
if cub == "PROYECTOS":
a = R.app_proyecto(f["ext"]) or "Sin programa reconocido"
apps[a][0] += f["n"]
apps[a][1] += f["b"]
con.close()
# cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta
roles = defaultdict(list)
for rol, cub in R.ROL_A_CUBETA.items():
roles[cub].append(R.ROL_QUE.get(rol, rol))
raiz_nodo = {"nombre": os.path.basename(raiz.rstrip(os.sep)) or raiz if raiz else "Carpeta del cliente",
"hijos": [], "cubetas": [], "variable": "", "nota": "", "n": 0, "bytes": 0}
def bajar(nodo, nombre, variable):
for h in nodo["hijos"]:
if h["nombre"].lower() == nombre.lower():
return h
h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "", "n": 0, "bytes": 0}
nodo["hijos"].append(h)
return h
cubetas = []
for cub, titulo in R.CUBETAS.items():
plantilla = pl.get(cub, "")
n, b = hay.get(cub, [0, 0])
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
"asignable": cub not in ("CONTENEDOR", "IGNORAR"),
"defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""),
"guarda": sorted(set(roles.get(cub, []))), "n": n, "bytes": b,
"apps": (sorted(({"nombre": k, "n": v[0], "bytes": v[1]} for k, v in apps.items()),
key=lambda x: -x["bytes"]) if cub == "PROYECTOS" else [])})
if not plantilla:
continue
nodo = raiz_nodo
for seg in plantilla.split("/"):
if seg == "{app}":
nodo = bajar(nodo, EJEMPLO_VAR["app"][0], EJEMPLO_VAR["app"][1])
nodo["nota"] = ("solo cuando el proyecto va suelto; si ya viene dentro de su propia "
"carpeta, se queda con el nombre de esa carpeta")
nodo["apps_de"] = cub # aquí es donde se busca «¿cuánto tengo de cada programa?»
continue
nota = next((v for k, v in VAR_NOTA.items() if seg == "{" + k + "}"), "")
if nota:
nodo["nota"] = nota # no baja un nivel: se queda en la carpeta de arriba
continue
texto, hint = seg, ""
for k, (ej, que) in EJEMPLO_VAR.items():
if "{" + k + "}" in texto:
texto = texto.replace("{" + k + "}", ej)
hint = que if not hint else hint + " y " + que
nodo = bajar(nodo, texto, hint)
nodo["cubetas"].append(cub)
nodo["n"] += n
nodo["bytes"] += b
def ordenar(nodo):
# una carpeta con nombre fijo antes que una variable, y dentro de cada grupo por nombre
nodo["hijos"].sort(key=lambda h: (bool(h["variable"]), h["nombre"].lower()))
for h in nodo["hijos"]:
ordenar(h)
nodo["n"] += h["n"]
nodo["bytes"] += h["bytes"]
ordenar(raiz_nodo)
return {"tipo": tipo, "titulo": R.TIPOS_CLIENTE.get(tipo, tipo), "raiz": raiz or "", "cliente": cliente,
"arbol": raiz_nodo, "cubetas": cubetas, "variables": {k: v[1] for k, v in EJEMPLO_VAR.items()}}
def acciones(raiz):
"""Todo lo que se puede hacer AHORA MISMO en esta carpeta, en orden, cada cosa con su cifra.
@@ -2831,7 +3045,21 @@ def acciones(raiz):
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera",
detalle="Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave)
# --- 5. stock de Envato viejo ---
# --- 5. carpetas que quedaron vacías ---
vac = carpetas_vacias(raiz)
if vac["n"]:
dentro = sum(c["subcarpetas"] for c in vac["carpetas"])
añadir(clave="vacias", titulo="Borrar carpetas vacías", boton="Ver y borrar",
que="Carpetas sin un solo archivo dentro: lo que queda después de mover el material.",
cifra=_plural(vac["n"], "carpeta", "carpetas") + (f" · {dentro} dentro" if dentro else ""),
detalle="Es lo único que la app borra, y solo porque no hay nada dentro. Se revisan una a "
"una y se deshace desde Historial.",
ir="vacias")
else:
añadir(clave="vacias", titulo="Carpetas vacías", estado="hecho",
que="No queda ninguna carpeta hueca.", cifra="", detalle="")
# --- 6. stock de Envato viejo ---
tope = limite_meses(3)
viejo = [f for f in con.execute(
"SELECT bytes, fecha, anio_ruta, mes_ruta FROM archivos WHERE raiz = ? AND es_stock = 1 AND es_basura = 0"
@@ -3078,6 +3306,15 @@ class Manejador(BaseHTTPRequestHandler):
return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos))
if ruta == "/api/papelera":
return self._json(papelera(os.path.normpath(q["raiz"])))
if ruta == "/api/vacias":
return self._json(carpetas_vacias(os.path.normpath(q["raiz"])))
if ruta == "/api/estructura":
raiz = q.get("raiz")
tipo = q.get("tipo")
if raiz and not tipo:
r = raiz_cfg(os.path.normpath(raiz))
tipo = r.get("tipo") if r else None
return self._json(mapa_estructura(tipo or "contenido", os.path.normpath(raiz) if raiz else None))
if ruta == "/api/duplicados":
return self._json(duplicados(os.path.normpath(q["raiz"])))
if ruta == "/api/respaldo/plan":
@@ -3163,8 +3400,10 @@ class Manejador(BaseHTTPRequestHandler):
guardar_json(DICC_PATH, DICC)
return self._json({"ok": True, "reglas": DICC["reglas"]})
if ruta == "/api/plantillas":
PLANTILLAS.update(d.get("plantillas", {}))
for t, cubs in (d.get("plantillas") or {}).items():
PLANTILLAS.setdefault(t, {}).update(cubs or {})
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
PLANES.clear() # los planes calculados apuntaban a los destinos viejos
return self._json({"ok": True})
if ruta == "/api/aplicar":
raiz = os.path.normpath(d["raiz"])
@@ -3261,6 +3500,8 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"resultados": reemplazar(d.get("ids", []))})
if ruta == "/api/stock/papelera":
return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", [])))
if ruta == "/api/vacias/borrar":
return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", [])))
if ruta == "/api/duplicados/apartar":
return self._json(apartar_duplicados(os.path.normpath(d["raiz"])))
if ruta == "/api/abrir":