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:
255
organizador.py
255
organizador.py
@@ -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":
|
||||
|
||||
Referencia en una nueva incidencia
Block a user