diff --git a/README.md b/README.md index 833e083..9ebd58c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Iniciar.bat ← doble clic. Abre http://127.0.0.1:8765 en el navegado organizador.py ← servidor + inventario + plan + cola de conversión reglas.py ← cómo reconoce cámaras, carpetas, basura y las plantillas de estructura resolve_bridge.py ← convierte BRAW a H.265 10 bits usando DaVinci Resolve Studio -static/index.html ← la interfaz (Carpeta · Revisar y ordenar · Convertir · Respaldar · Historial · Ajustes) +static/index.html ← la interfaz (Carpeta · Revisar y ordenar · Mapa · Convertidor · Respaldar · Historial · Ajustes) datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl ``` @@ -428,6 +428,39 @@ preselecciona el tipo que ese cliente ya tiene; antes salía siempre «contenido de un cliente de pauta o de proyecto se podía registrar con la plantilla equivocada. Y el diálogo dice al final que al confirmar solo lee y arma una lista: no mueve ni un archivo. +2.3.0, tres cosas para la reorganización: + +**Los proyectos se separan por programa.** En una carpeta de cliente conviven `.prproj`, `.aep`, `.drp` y +`.psd`, y lo que cuesta es encontrarlos mezclados. La plantilla de proyectos pasa de `01_Proyectos/{nombre}` +a `01_Proyectos/{app}/{nombre}`, con `{app}` = el programa que abre el archivo (`R.APPS_PROYECTO`, +`R.app_proyecto`): Premiere, After Effects, DaVinci Resolve, Photoshop, Illustrator, InDesign, Audition, +Affinity, Figma y XD, Cinema 4D, Blender, Motion, Plantillas e Intercambio. Un proyecto que ya viene dentro +de su propia carpeta se queda con el nombre de esa carpeta; lo que se separa es lo suelto. Las plantillas +guardadas de una instalación anterior se migran solas si nadie las había tocado a mano. De paso entran +extensiones que caían en «otro» y acababan en `_Revisar`: `.sesx`, `.c4d`, `.blend`, `.motn`, `.aepx`, +`.aet`, `.prel`, `.idml`, `.psdt`, `.afpub`. + +**Borrar carpetas vacías.** Ordenar deja huecos: repartida «Grabacion» por mes y por cámara, la carpeta +original se queda sin nada, y `quitar_vacias` solo limpiaba el camino de lo que se acababa de mover. Nueva +sub-pestaña en «Revisar y ordenar» y nuevo paso 5 en la lista de acciones. Los candidatos salen del +inventario (instantáneo) y cada uno se comprueba en el disco antes de enseñarlo y otra vez antes de +borrarlo; una carpeta que dejó de estar vacía se salta y se avisa. Solo sale la de más arriba de cada rama. +Los enlaces y las uniones de Windows no se tocan nunca, aunque parezcan vacíos. **Es lo único que la app +borra**, y solo porque no hay nada dentro que perder: cada carpeta queda en la bitácora como `rmdir` y +«Deshacer» las vuelve a crear con su nombre y las devuelve al inventario. No entra en «Hacer las N de una +vez»: hay que mirar la lista, porque una carpeta vacía llamada «03- Exfoliación química» es lo único que +queda de esa pieza. En el material real: 29 huecos en David Reyes y 40 en Camilo Ortega. + +**Mapa.** Pestaña nueva con el árbol de carpetas que produce cada plantilla, armado con las plantillas de +verdad (`mapa_estructura`, `GET /api/estructura`), así que enseña exactamente lo que la app va a hacer. Las +carpetas variables van en cursiva; al tocar cualquiera, el panel de al lado dice qué guarda (derivado de +`ROL_A_CUBETA` y `R.ROL_QUE`, o sea que no se desfasa), cuánto material de eso hay hoy en la carpeta, y deja +cambiar el destino ahí mismo. En la carpeta de un programa sale el desglose: en David Reyes, Photoshop 10,6 +GB, Affinity 10,3 GB, DaVinci Resolve 172 MB, Illustrator 146 MB, After Effects 736 KB. Abajo, «Sin carpeta +asignada» lista el material que no tiene dónde ir con ese tipo de trabajo y deja añadirle una carpeta. +`POST /api/plantillas` pasa a fusionar cubeta a cubeta: con `update()` de primer nivel, guardar una sola +plantilla borraba las otras diecinueve. + Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: diff --git a/manifiesto.json b/manifiesto.json index 60fa014..43e3527 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "2.2.1", - "builtAt": "2026-09-17T03:09:00.000Z", - "archivo": "OrganizadorLabstream-portatil-2.2.1.zip", - "size": 198701797, - "sha256": "ef57218224a0f471b8bc4e6084e1900eaf81b246d8adcda85adfc7d8e2e6cd61", + "version": "2.3.0", + "builtAt": "2026-09-17T03:31:56.000Z", + "archivo": "OrganizadorLabstream-portatil-2.3.0.zip", + "size": 198716642, + "sha256": "1fe85cae6053dca7b0b381312fcac570d4595a78b7fd55254662f61d2be8521b", "app": { - "archivo": "OrganizadorLabstream-app-2.2.1.zip", - "size": 14882048, - "sha256": "9d2d42e964717219da5f19e51ae3a82a4f1f3b010e6b3a97c35a6a69f50ce284", - "url": "/api/herramientas/OrganizadorLabstream-app-2.2.1.zip" + "archivo": "OrganizadorLabstream-app-2.3.0.zip", + "size": 14896889, + "sha256": "731ea6a9477d0bf2c04cc26a8a92fe6615e5e45ed1313d43020de16c7599ca70", + "url": "/api/herramientas/OrganizadorLabstream-app-2.3.0.zip" }, "notes": "", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "5e0d56e2a07702140fc9ffdb99ccd2427f3115040760699c350ff0ab538cda5bb93ab665cce32a14087f32f2e595a1c4de548578fe9469cfd73f9821991f42c9bc526f09de5375aff79c6a063a88482d71eab6c831e78bce5536ffcba330384e8a4d522eb677f1ebb9478cda13bc7652a18279480c213798069ab1933a0dc082a1cd25dce90d890f1c6f439aa32295f4ffac7d0075254e21b0f303527e2ff0d828065d3c589e595de2a61a3c7f29314e3ecf15831a3ff7a3c44324988fe124a2c94d4ebf42c973865a4dd176b848f9cb5654065138177dd8f7b784469dff088605182bee33751428ff609731c6171efed010d59cbdf3c60976fe11c00c53b5e6" + "valor": "bd0526e1cb3db8a7a6f8ecd0ddac54d627dee221713adba628eb6bbde1e601952770aebcc8ca07c379047af960117c0403cb12b19d80bf8a61a607d330365c3facd34d49b6c615e7cd6c1bd92eb9878f0961fa7f85953d235368abdf8704a12066ed6b31f2588201ef175fef264db0c14b318f50b587afbe73246405cbe423fe403376e00ff7610f89948806169ea3e13cd4aaf9b30565bba184083b11e9e49c01157b340d2e019bc23aed6aaa7b992e5ec7a2408c476352c603b6e5c5601a282d9e0eb6e724036c01b12f06011e23d538dae83ded019475800cfe62876b3a6d42b23f4fbadd1371b5b039328a44e33a8cadb94e630c39a30883b33a78b2d3ab" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 2995e02..4ea666a 100644 --- a/organizador.py +++ b/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": diff --git a/probar.py b/probar.py index 6f4fb7d..7004bda 100644 --- a/probar.py +++ b/probar.py @@ -46,6 +46,15 @@ class Reglas(unittest.TestCase): self.assertEqual(R.detectar_anio("2026"), 2026) self.assertIsNone(R.detectar_anio("2026 - agosto")) + def test_app_proyecto(self): + self.assertEqual(R.app_proyecto(".prproj"), "Premiere") + self.assertEqual(R.app_proyecto(".AEP"), "After Effects") + self.assertEqual(R.app_proyecto(".drp"), "DaVinci Resolve") + self.assertEqual(R.app_proyecto(".psd"), "Photoshop") + self.assertIsNone(R.app_proyecto(".mp4")) + # la plantilla de proyectos separa por programa + self.assertIn("{app}", R.PLANTILLAS["contenido"]["PROYECTOS"]) + def test_basura_y_proxies(self): self.assertTrue(R.es_basura_archivo("Thumbs.db")) self.assertTrue(R.es_basura_archivo(".DS_Store")) @@ -205,6 +214,47 @@ class Servidor(unittest.TestCase): c, res = self.post("/api/deshacer", {"lote": res["lote"]}) self.assertEqual(c, 200) self.assertEqual(antes, self._arbol()) + # 4b. el mapa dice dónde va a acabar cada cosa, con las plantillas de verdad + est = self.get("/api/estructura" + q) + proy = next(c for c in est["cubetas"] if c["clave"] == "PROYECTOS") + self.assertIn("{app}", proy["plantilla"]) + self.assertTrue(proy["guarda"]) + nombres = [] + + def bajar(n): + nombres.append(n["nombre"]) + for h in n["hijos"]: + bajar(h) + bajar(est["arbol"]) + self.assertIn("00_Grabacion", nombres) + self.assertIn("Premiere", nombres) + # guardar UNA plantilla no puede llevarse por delante las demás + antes_pl = len([c for c in est["cubetas"] if c["plantilla"]]) + self.assertEqual(self.post("/api/plantillas", {"plantillas": {"contenido": {"MUSICA": "_Recursos/Musica/{sub}"}}})[0], 200) + self.assertEqual(len([c for c in self.get("/api/estructura" + q)["cubetas"] if c["plantilla"]]), antes_pl) + + # 4c. carpetas vacías: se encuentran, se borran sin tocar un archivo y deshacer las devuelve + for rel in ("Huecos/uno", "Huecos/dos", "Sola"): + os.makedirs(os.path.join(self.raiz, rel.replace("/", os.sep)), exist_ok=True) + self.post("/api/escanear", {"raiz": self.raiz}) + self.esperar_escaneo() + v = self.get("/api/vacias" + q) + rels = {c["rel"] for c in v["carpetas"]} + self.assertIn("Huecos", rels) # el tope, no cada hija por separado + self.assertIn("Sola", rels) + self.assertNotIn(os.path.join("Huecos", "uno"), rels) + arbol_antes = self._arbol() + c, res = self.post("/api/vacias/borrar", {"raiz": self.raiz, "rutas": [x["ruta"] for x in v["carpetas"]]}) + self.assertEqual(c, 200) + self.assertEqual(res["errores"], []) + self.assertEqual(res["borradas"], 4) # Huecos con sus dos hijas, y Sola + self.assertFalse(os.path.isdir(os.path.join(self.raiz, "Sola"))) + self.assertEqual(arbol_antes, self._arbol()) # ni un archivo tocado + c, _ = self.post("/api/deshacer", {"lote": res["lote"]}) + self.assertEqual(c, 200) + self.assertTrue(os.path.isdir(os.path.join(self.raiz, "Huecos", "uno"))) + self.post("/api/vacias/borrar", {"raiz": self.raiz, "rutas": [x["ruta"] for x in v["carpetas"]]}) + # 5. desconectar con datos; la segunda vez ya no existe c, d = self.post("/api/raiz/quitar", {"raiz": self.raiz, "datos": True}) self.assertEqual(c, 200) diff --git a/reglas.py b/reglas.py index 5b9db47..5746c94 100644 --- a/reglas.py +++ b/reglas.py @@ -150,14 +150,44 @@ EXT = { "audio": {".wav", ".mp3", ".aac", ".m4a", ".aif", ".aiff", ".flac", ".ogg", ".wma"}, "imagen": {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp", ".svg"}, "foto_raw": {".arw", ".rw2", ".dng", ".cr2", ".cr3", ".nef", ".raf", ".xmp"}, - "diseno": {".psd", ".psb", ".ai", ".af", ".afphoto", ".afdesign", ".indd", ".xd", ".fig", ".sketch", ".eps"}, - "proyecto": {".drp", ".drb", ".drt", ".dra", ".prproj", ".aep", ".prin", ".fcpxml", ".aaf", ".edl", ".otio", ".xml", ".comp", ".setting", ".mogrt", ".drfx"}, + "diseno": {".psd", ".psb", ".psdt", ".ai", ".af", ".afphoto", ".afdesign", ".afpub", ".indd", ".idml", + ".xd", ".fig", ".sketch", ".eps"}, + "proyecto": {".drp", ".drb", ".drt", ".dra", ".prproj", ".prel", ".ppj", ".prtl", ".aep", ".aepx", ".aet", + ".prin", ".fcpxml", ".aaf", ".edl", ".otio", ".xml", ".comp", ".setting", ".mogrt", ".drfx", + ".sesx", ".c4d", ".blend", ".motn"}, "documento": {".pdf", ".docx", ".doc", ".md", ".txt", ".pptx", ".xlsx", ".csv", ".rtf", ".ttml", ".srt", ".vtt", ".pages", ".key", ".numbers"}, "fuente": {".ttf", ".otf", ".woff", ".woff2"}, "comprimido": {".zip", ".rar", ".7z", ".tar", ".gz", ".dmg", ".iso", ".img"}, "lut": {".cube", ".3dl", ".look"}, "programa": {".exe", ".msi", ".pkg", ".app", ".lnk"}, } +# Qué programa abre cada proyecto. «01_Proyectos» se separa por programa porque en la carpeta de un +# cliente conviven .prproj, .aep, .drp y .psd, y lo que cuesta es encontrarlos mezclados. El nombre de +# la carpeta es el del programa tal como se dice en el estudio. +APPS_PROYECTO = { + "Premiere": (".prproj", ".prel", ".ppj", ".prtl", ".prin"), + "After Effects": (".aep", ".aepx", ".aet"), + "DaVinci Resolve": (".drp", ".drb", ".drt", ".dra", ".drfx", ".setting", ".comp"), + "Photoshop": (".psd", ".psb", ".psdt"), + "Illustrator": (".ai", ".eps"), + "InDesign": (".indd", ".idml"), + "Audition": (".sesx",), + "Affinity": (".af", ".afphoto", ".afdesign", ".afpub"), + "Figma y XD": (".fig", ".xd", ".sketch"), + "Cinema 4D": (".c4d",), + "Blender": (".blend",), + "Motion": (".motn",), + "Plantillas": (".mogrt",), + "Intercambio": (".fcpxml", ".aaf", ".edl", ".otio", ".xml"), +} +EXT_A_APP = {e: a for a, exts in APPS_PROYECTO.items() for e in exts} + + +def app_proyecto(ext: str): + """«.prproj» -> «Premiere». None si la extensión no es de un proyecto de edición o diseño.""" + return EXT_A_APP.get((ext or "").lower()) + + BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"} # solo temporales y cachés que un programa regenera solo. NO se ponen aquí extensiones de datos # (.dat, .idx, .bak): son de plugins, subtítulos o copias que la gente guarda a propósito. @@ -326,7 +356,7 @@ CUBETAS = { "PORTADAS": "Portadas y miniaturas", "APOYOS": "Tomas de apoyo reutilizables (b-roll)", "MARCA": "Material de marca (logos, fuentes, fondos, transiciones)", - "PROYECTOS": "Proyectos de edición (Resolve, Premiere, AE, Photoshop)", + "PROYECTOS": "Proyectos de edición, separados por programa (Premiere, After Effects, Resolve, Photoshop…)", "MUSICA": "Música", "SFX": "Efectos de sonido", "VOZ": "Voces / locuciones", @@ -479,11 +509,14 @@ TIPOS_CLIENTE = { "interno": "Es material de Labstream · se ordena por tipo de recurso", } -# Destinos relativos a la raíz del cliente. {anio} {mes} = "08-Agosto" {nombre} {fuente} +# Destinos relativos a la raíz del cliente. Variables: {anio} {mes} ("08- Agosto") {nombre} {sub} +# {fuente} (la cámara) {app} (el programa del proyecto) {rel} (la ruta tal cual venía). +# Una variable vacía se lleva consigo su segmento: «01_Proyectos/{app}/{nombre}» sin programa queda +# en «01_Proyectos/Reel botox». PLANTILLAS = { "contenido": { "MARCA": "00_Marca/{sub}", - "PROYECTOS": "01_Proyectos/{nombre}", + "PROYECTOS": "01_Proyectos/{app}/{nombre}", "GRABACION": "{anio}/{mes}/00_Grabacion/{sub}", "PIEZA": "{anio}/{mes}/{nombre}", "CAMPANA": "Campañas/{anio}_{nombre}", @@ -505,7 +538,7 @@ PLANTILLAS = { }, "pauta": { "MARCA": "00_Marca/{sub}", - "PROYECTOS": "01_Proyectos/{nombre}", + "PROYECTOS": "01_Proyectos/{app}/{nombre}", "GRABACION": "Material/Grabaciones/{anio}/{mes}/{sub}", "PIEZA": "Campañas/{anio}_{nombre}", "CAMPANA": "Campañas/{anio}_{nombre}", @@ -527,7 +560,7 @@ PLANTILLAS = { }, "proyecto": { "MARCA": "00_Marca/{sub}", - "PROYECTOS": "03_Proyecto/{nombre}", + "PROYECTOS": "03_Proyecto/{app}/{nombre}", "GRABACION": "02_Grabacion/{anio}/{mes}/{sub}", "PIEZA": "04_Exportados/{nombre}", "CAMPANA": "{anio}_{nombre}", @@ -549,7 +582,7 @@ PLANTILLAS = { }, "interno": { "MARCA": "Marca/{sub}", - "PROYECTOS": "Proyectos/{nombre}", + "PROYECTOS": "Proyectos/{app}/{nombre}", "GRABACION": "Grabaciones/{anio}/{mes}/{sub}", "PIEZA": "Piezas/{anio}/{nombre}", "CAMPANA": "Campañas/{anio}_{nombre}", @@ -585,6 +618,33 @@ CARPETAS_PROPIAS = {norm(x) for x in ( "00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados")} # Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio) +# Qué es cada rol dicho en llano, para que el mapa pueda explicar qué cae en cada carpeta. +ROL_QUE = { + "camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)", + "camara_video": "clips de cámara (URSA, Sony, DJI, Osmo, Atomos)", + "camara_sidecar": "los .xml que Sony deja junto a cada clip", + "audio_grabado": "audio de grabadora o micro (DJI Mic, Tascam)", + "celular_video": "video de celular y de WhatsApp", + "celular_foto": "fotos de celular y de WhatsApp", + "foto_camara": "fotos de cámara (.arw, .cr3, .nef, .dng)", + "ia": "material generado con IA", + "stock": "tomas de stock (Envato y similares)", + "captura": "capturas de pantalla", + "proxy": "proxies (.lrf y los que crean Resolve y Premiere)", + "video": "video que no viene de cámara: exportados y masters", + "audio": "música y audio (.wav, .mp3, .aac)", + "imagen": "imágenes (.jpg, .png, .tif)", + "diseno": "diseño (.psd, .ai, .indd, Affinity)", + "proyecto": "proyectos de edición (.prproj, .aep, .drp)", + "documento": "documentos, guiones y subtítulos (.pdf, .docx, .srt)", + "fuente": "tipografías (.ttf, .otf)", + "comprimido": "comprimidos (.zip, .rar)", + "lut": "LUTs (.cube, .3dl)", + "programa": "instaladores y ejecutables", + "otro": "lo que no encaja en ninguna otra cosa", + "basura": "basura de sistema y cachés", +} + ROL_A_CUBETA = { "camara_raw": "GRABACION", "camara_video": "GRABACION", "camara_sidecar": "GRABACION", "audio_grabado": "GRABACION", "celular_video": "CELULAR", "celular_foto": "CELULAR", diff --git a/static/index.html b/static/index.html index f6092de..85d3ccd 100644 --- a/static/index.html +++ b/static/index.html @@ -150,6 +150,22 @@ .ejtipo div{display:flex;gap:4px 10px;align-items:baseline;flex-wrap:wrap} .ejtipo b{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;font-weight:600;word-break:break-all} .ejtipo span{font-size:12px;color:var(--mut)} + .arbol{font-family:ui-monospace,Consolas,monospace;font-size:12px;line-height:1.55} + .arbol .nd{display:flex;gap:10px;align-items:baseline;padding:3px 7px;border-radius:5px;cursor:pointer} + .arbol .nd:hover{background:var(--panel2)} + .arbol .nd.sel{background:var(--acc2);color:var(--acc)} + .arbol .nd .nm{font-weight:600} + .arbol .nd.var .nm{font-style:italic;font-weight:500} + .arbol .leyenda{margin-top:10px;font-family:Manrope,system-ui,sans-serif;font-size:11.5px;color:var(--mut)} + .arbol .nd .cif{margin-left:auto;font-family:Manrope,system-ui,sans-serif;font-size:11px;color:var(--mut);white-space:nowrap} + .arbol .hijos{margin-left:9px;border-left:1px solid var(--line);padding-left:11px} + .arbol .vacio{color:var(--mut);font-weight:400} + .det h4{margin:0 0 4px;font-size:13.5px} + .det .ruta{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;word-break:break-all;color:var(--mut);margin-bottom:10px} + .det ul{margin:4px 0 0;padding-left:18px;font-size:12.5px} + .det .vars{display:grid;gap:3px;margin-top:6px;font-size:11.5px;color:var(--mut)} + .det .vars code{font-family:ui-monospace,Consolas,monospace;color:var(--tx)} + .sinsitio{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--line2)} pre{background:var(--panel2);padding:10px;border-radius:6px;overflow:auto;font-size:11.5px;max-height:280px} .tree{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;white-space:pre;line-height:1.5;overflow:auto;max-height:420px} .hint{background:var(--acc2);border:0;border-left:3px solid var(--acc);border-radius:0 6px 6px 0;padding:9px 12px;color:var(--tx);font-size:12.5px;margin-bottom:14px} @@ -309,6 +325,7 @@