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

@@ -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 organizador.py ← servidor + inventario + plan + cola de conversión
reglas.py ← cómo reconoce cámaras, carpetas, basura y las plantillas de estructura 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 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 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 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. 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 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ó 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: con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -1,19 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "2.2.1", "version": "2.3.0",
"builtAt": "2026-09-17T03:09:00.000Z", "builtAt": "2026-09-17T03:31:56.000Z",
"archivo": "OrganizadorLabstream-portatil-2.2.1.zip", "archivo": "OrganizadorLabstream-portatil-2.3.0.zip",
"size": 198701797, "size": 198716642,
"sha256": "ef57218224a0f471b8bc4e6084e1900eaf81b246d8adcda85adfc7d8e2e6cd61", "sha256": "1fe85cae6053dca7b0b381312fcac570d4595a78b7fd55254662f61d2be8521b",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-2.2.1.zip", "archivo": "OrganizadorLabstream-app-2.3.0.zip",
"size": 14882048, "size": 14896889,
"sha256": "9d2d42e964717219da5f19e51ae3a82a4f1f3b010e6b3a97c35a6a69f50ce284", "sha256": "731ea6a9477d0bf2c04cc26a8a92fe6615e5e45ed1313d43020de16c7599ca70",
"url": "/api/herramientas/OrganizadorLabstream-app-2.2.1.zip" "url": "/api/herramientas/OrganizadorLabstream-app-2.3.0.zip"
}, },
"notes": "", "notes": "",
"firma": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "5e0d56e2a07702140fc9ffdb99ccd2427f3115040760699c350ff0ab538cda5bb93ab665cce32a14087f32f2e595a1c4de548578fe9469cfd73f9821991f42c9bc526f09de5375aff79c6a063a88482d71eab6c831e78bce5536ffcba330384e8a4d522eb677f1ebb9478cda13bc7652a18279480c213798069ab1933a0dc082a1cd25dce90d890f1c6f439aa32295f4ffac7d0075254e21b0f303527e2ff0d828065d3c589e595de2a61a3c7f29314e3ecf15831a3ff7a3c44324988fe124a2c94d4ebf42c973865a4dd176b848f9cb5654065138177dd8f7b784469dff088605182bee33751428ff609731c6171efed010d59cbdf3c60976fe11c00c53b5e6" "valor": "bd0526e1cb3db8a7a6f8ecd0ddac54d627dee221713adba628eb6bbde1e601952770aebcc8ca07c379047af960117c0403cb12b19d80bf8a61a607d330365c3facd34d49b6c615e7cd6c1bd92eb9878f0961fa7f85953d235368abdf8704a12066ed6b31f2588201ef175fef264db0c14b318f50b587afbe73246405cbe423fe403376e00ff7610f89948806169ea3e13cd4aaf9b30565bba184083b11e9e49c01157b340d2e019bc23aed6aaa7b992e5ec7a2408c476352c603b6e5c5601a282d9e0eb6e724036c01b12f06011e23d538dae83ded019475800cfe62876b3a6d42b23f4fbadd1371b5b039328a44e33a8cadb94e630c39a30883b33a78b2d3ab"
} }
} }

Ver fichero

@@ -34,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__] actualizador.O = sys.modules[__name__]
VERSION = "2.2.1" VERSION = "2.3.0"
CONGELADO = getattr(sys, "frozen", False) 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 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. # mientras toda la interfaz hablaba de la nueva. Se migra una sola vez.
if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"): if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"):
PLANTILLAS[t][b] = p 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) guardar_json(PLANTILLAS_PATH, PLANTILLAS)
@@ -622,6 +625,7 @@ def destino_plantilla(tipo, cubeta, **kw):
"sub": kw.get("sub", ""), "sub": kw.get("sub", ""),
"rel": kw.get("rel", ""), "rel": kw.get("rel", ""),
"fuente": kw.get("fuente", ""), "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" # un valor vacío se lleva consigo el separador que lo precedía: "{anio}-{mes}_Grabacion" sin mes -> "2026_Grabacion"
vacio = "\x00" vacio = "\x00"
@@ -1093,15 +1097,17 @@ class Plan:
conf = "media" if nivel == "mes" else "baja" conf = "media" if nivel == "mes" else "baja"
motivo = "video suelto: parece un exportado" + (" · mes por fecha" if not mes else "") motivo = "video suelto: parece un exportado" + (" · mes por fecha" if not mes else "")
if cub == "PROYECTOS": if cub == "PROYECTOS":
sub = {"diseno": "Diseño", "proyecto": "Edicion"}.get(rol, "") # el programa manda sobre el rol: un .aep y un .psd sueltos en la misma carpeta van cada uno
motivo = "proyecto de edición / diseño suelto" # 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" conf = "alta"
if cub == "MUSICA" and rol == "audio": if cub == "MUSICA" and rol == "audio":
conf = "baja" conf = "baja"
if cub == "REVISAR": if cub == "REVISAR":
destino = "_Revisar" destino = "_Revisar"
elif cub == "PROYECTOS": elif cub == "PROYECTOS":
destino = destino_plantilla(self.tipo, "PROYECTOS", nombre=sub) destino = destino_plantilla(self.tipo, "PROYECTOS", app=sub)
else: else:
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, sub=sub, nombre="") destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, sub=sub, nombre="")
self.suelto(fila, cub, destino, motivo, conf) self.suelto(fila, cub, destino, motivo, conf)
@@ -1335,16 +1341,111 @@ def quitar_vacias(ruta, tope):
ruta = os.path.dirname(ruta) 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): def deshacer(lote):
todas = leer_bitacora(100000) todas = leer_bitacora(100000)
entradas = [e for e in todas if e.get("lote") == lote and e.get("op") in ("mover", "rmdir")] 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"), {}) fin = next((e for e in todas if e.get("lote") == lote and e.get("op") == "fin"), {})
raiz = fin.get("raiz") or "" raiz = fin.get("raiz") or ""
deshechos, errores, movidos = 0, [], [] deshechos, errores, movidos, recreadas = 0, [], [], []
for e in reversed(entradas): for e in reversed(entradas):
try: try:
if e["op"] == "rmdir": if e["op"] == "rmdir":
os.makedirs(e["de"], exist_ok=True) 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: else:
if not os.path.exists(e["a"]): 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") 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: except OSError as ex:
errores.append(f"{e.get('a')}: {ex}") errores.append(f"{e.get('a')}: {ex}")
actualizar_inventario(movidos) 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}) bitacora({"lote": lote + "-deshecho", "op": "deshacer", "de_lote": lote, "movimientos": deshechos, "errores": errores})
return {"deshechos": 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) 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): def acciones(raiz):
"""Todo lo que se puede hacer AHORA MISMO en esta carpeta, en orden, cada cosa con su cifra. """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", 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="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) tope = limite_meses(3)
viejo = [f for f in con.execute( 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" "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)) return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos))
if ruta == "/api/papelera": if ruta == "/api/papelera":
return self._json(papelera(os.path.normpath(q["raiz"]))) 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": if ruta == "/api/duplicados":
return self._json(duplicados(os.path.normpath(q["raiz"]))) return self._json(duplicados(os.path.normpath(q["raiz"])))
if ruta == "/api/respaldo/plan": if ruta == "/api/respaldo/plan":
@@ -3163,8 +3400,10 @@ class Manejador(BaseHTTPRequestHandler):
guardar_json(DICC_PATH, DICC) guardar_json(DICC_PATH, DICC)
return self._json({"ok": True, "reglas": DICC["reglas"]}) return self._json({"ok": True, "reglas": DICC["reglas"]})
if ruta == "/api/plantillas": 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) guardar_json(PLANTILLAS_PATH, PLANTILLAS)
PLANES.clear() # los planes calculados apuntaban a los destinos viejos
return self._json({"ok": True}) return self._json({"ok": True})
if ruta == "/api/aplicar": if ruta == "/api/aplicar":
raiz = os.path.normpath(d["raiz"]) raiz = os.path.normpath(d["raiz"])
@@ -3261,6 +3500,8 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"resultados": reemplazar(d.get("ids", []))}) return self._json({"resultados": reemplazar(d.get("ids", []))})
if ruta == "/api/stock/papelera": if ruta == "/api/stock/papelera":
return self._json(stock_a_papelera(os.path.normpath(d["raiz"]), d.get("rutas", []))) 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": if ruta == "/api/duplicados/apartar":
return self._json(apartar_duplicados(os.path.normpath(d["raiz"]))) return self._json(apartar_duplicados(os.path.normpath(d["raiz"])))
if ruta == "/api/abrir": if ruta == "/api/abrir":

Ver fichero

@@ -46,6 +46,15 @@ class Reglas(unittest.TestCase):
self.assertEqual(R.detectar_anio("2026"), 2026) self.assertEqual(R.detectar_anio("2026"), 2026)
self.assertIsNone(R.detectar_anio("2026 - agosto")) 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): def test_basura_y_proxies(self):
self.assertTrue(R.es_basura_archivo("Thumbs.db")) self.assertTrue(R.es_basura_archivo("Thumbs.db"))
self.assertTrue(R.es_basura_archivo(".DS_Store")) 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"]}) c, res = self.post("/api/deshacer", {"lote": res["lote"]})
self.assertEqual(c, 200) self.assertEqual(c, 200)
self.assertEqual(antes, self._arbol()) 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 # 5. desconectar con datos; la segunda vez ya no existe
c, d = self.post("/api/raiz/quitar", {"raiz": self.raiz, "datos": True}) c, d = self.post("/api/raiz/quitar", {"raiz": self.raiz, "datos": True})
self.assertEqual(c, 200) self.assertEqual(c, 200)

Ver fichero

@@ -150,14 +150,44 @@ EXT = {
"audio": {".wav", ".mp3", ".aac", ".m4a", ".aif", ".aiff", ".flac", ".ogg", ".wma"}, "audio": {".wav", ".mp3", ".aac", ".m4a", ".aif", ".aiff", ".flac", ".ogg", ".wma"},
"imagen": {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp", ".svg"}, "imagen": {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp", ".svg"},
"foto_raw": {".arw", ".rw2", ".dng", ".cr2", ".cr3", ".nef", ".raf", ".xmp"}, "foto_raw": {".arw", ".rw2", ".dng", ".cr2", ".cr3", ".nef", ".raf", ".xmp"},
"diseno": {".psd", ".psb", ".ai", ".af", ".afphoto", ".afdesign", ".indd", ".xd", ".fig", ".sketch", ".eps"}, "diseno": {".psd", ".psb", ".psdt", ".ai", ".af", ".afphoto", ".afdesign", ".afpub", ".indd", ".idml",
"proyecto": {".drp", ".drb", ".drt", ".dra", ".prproj", ".aep", ".prin", ".fcpxml", ".aaf", ".edl", ".otio", ".xml", ".comp", ".setting", ".mogrt", ".drfx"}, ".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"}, "documento": {".pdf", ".docx", ".doc", ".md", ".txt", ".pptx", ".xlsx", ".csv", ".rtf", ".ttml", ".srt", ".vtt", ".pages", ".key", ".numbers"},
"fuente": {".ttf", ".otf", ".woff", ".woff2"}, "fuente": {".ttf", ".otf", ".woff", ".woff2"},
"comprimido": {".zip", ".rar", ".7z", ".tar", ".gz", ".dmg", ".iso", ".img"}, "comprimido": {".zip", ".rar", ".7z", ".tar", ".gz", ".dmg", ".iso", ".img"},
"lut": {".cube", ".3dl", ".look"}, "lut": {".cube", ".3dl", ".look"},
"programa": {".exe", ".msi", ".pkg", ".app", ".lnk"}, "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"} 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 # 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. # (.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", "PORTADAS": "Portadas y miniaturas",
"APOYOS": "Tomas de apoyo reutilizables (b-roll)", "APOYOS": "Tomas de apoyo reutilizables (b-roll)",
"MARCA": "Material de marca (logos, fuentes, fondos, transiciones)", "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", "MUSICA": "Música",
"SFX": "Efectos de sonido", "SFX": "Efectos de sonido",
"VOZ": "Voces / locuciones", "VOZ": "Voces / locuciones",
@@ -479,11 +509,14 @@ TIPOS_CLIENTE = {
"interno": "Es material de Labstream · se ordena por tipo de recurso", "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 = { PLANTILLAS = {
"contenido": { "contenido": {
"MARCA": "00_Marca/{sub}", "MARCA": "00_Marca/{sub}",
"PROYECTOS": "01_Proyectos/{nombre}", "PROYECTOS": "01_Proyectos/{app}/{nombre}",
"GRABACION": "{anio}/{mes}/00_Grabacion/{sub}", "GRABACION": "{anio}/{mes}/00_Grabacion/{sub}",
"PIEZA": "{anio}/{mes}/{nombre}", "PIEZA": "{anio}/{mes}/{nombre}",
"CAMPANA": "Campañas/{anio}_{nombre}", "CAMPANA": "Campañas/{anio}_{nombre}",
@@ -505,7 +538,7 @@ PLANTILLAS = {
}, },
"pauta": { "pauta": {
"MARCA": "00_Marca/{sub}", "MARCA": "00_Marca/{sub}",
"PROYECTOS": "01_Proyectos/{nombre}", "PROYECTOS": "01_Proyectos/{app}/{nombre}",
"GRABACION": "Material/Grabaciones/{anio}/{mes}/{sub}", "GRABACION": "Material/Grabaciones/{anio}/{mes}/{sub}",
"PIEZA": "Campañas/{anio}_{nombre}", "PIEZA": "Campañas/{anio}_{nombre}",
"CAMPANA": "Campañas/{anio}_{nombre}", "CAMPANA": "Campañas/{anio}_{nombre}",
@@ -527,7 +560,7 @@ PLANTILLAS = {
}, },
"proyecto": { "proyecto": {
"MARCA": "00_Marca/{sub}", "MARCA": "00_Marca/{sub}",
"PROYECTOS": "03_Proyecto/{nombre}", "PROYECTOS": "03_Proyecto/{app}/{nombre}",
"GRABACION": "02_Grabacion/{anio}/{mes}/{sub}", "GRABACION": "02_Grabacion/{anio}/{mes}/{sub}",
"PIEZA": "04_Exportados/{nombre}", "PIEZA": "04_Exportados/{nombre}",
"CAMPANA": "{anio}_{nombre}", "CAMPANA": "{anio}_{nombre}",
@@ -549,7 +582,7 @@ PLANTILLAS = {
}, },
"interno": { "interno": {
"MARCA": "Marca/{sub}", "MARCA": "Marca/{sub}",
"PROYECTOS": "Proyectos/{nombre}", "PROYECTOS": "Proyectos/{app}/{nombre}",
"GRABACION": "Grabaciones/{anio}/{mes}/{sub}", "GRABACION": "Grabaciones/{anio}/{mes}/{sub}",
"PIEZA": "Piezas/{anio}/{nombre}", "PIEZA": "Piezas/{anio}/{nombre}",
"CAMPANA": "Campañas/{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")} "00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados")}
# Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio) # 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 = { ROL_A_CUBETA = {
"camara_raw": "GRABACION", "camara_video": "GRABACION", "camara_sidecar": "GRABACION", "camara_raw": "GRABACION", "camara_video": "GRABACION", "camara_sidecar": "GRABACION",
"audio_grabado": "GRABACION", "celular_video": "CELULAR", "celular_foto": "CELULAR", "audio_grabado": "GRABACION", "celular_video": "CELULAR", "celular_foto": "CELULAR",

Ver fichero

@@ -150,6 +150,22 @@
.ejtipo div{display:flex;gap:4px 10px;align-items:baseline;flex-wrap:wrap} .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 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)} .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} 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} .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} .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 @@
<nav id="nav" aria-label="Vistas"> <nav id="nav" aria-label="Vistas">
<a data-t="carpeta" class="on" tabindex="0">Carpeta</a> <a data-t="carpeta" class="on" tabindex="0">Carpeta</a>
<a data-t="revisar" tabindex="0">Revisar y ordenar</a> <a data-t="revisar" tabindex="0">Revisar y ordenar</a>
<a data-t="mapa" tabindex="0" title="Cómo van a quedar las carpetas y qué va en cada una">Mapa</a>
<a data-t="convertir" tabindex="0">Convertidor</a> <a data-t="convertir" tabindex="0">Convertidor</a>
<a data-t="respaldar" tabindex="0">Respaldar</a> <a data-t="respaldar" tabindex="0">Respaldar</a>
<a data-t="historial" tabindex="0">Historial</a> <a data-t="historial" tabindex="0">Historial</a>
@@ -347,10 +364,10 @@
<!-- ============ REVISAR Y ORDENAR ============ --> <!-- ============ REVISAR Y ORDENAR ============ -->
<section id="t-revisar"> <section id="t-revisar">
<h2 class="v">Revisar y ordenar</h2> <h2 class="v">Revisar y ordenar</h2>
<p class="ayuda">Todo se mueve <b>dentro de la misma carpeta</b> y nada se borra. <span class="q" title="Más">?</span><span class="mas">Basura y proxies van a <span class="path">_Para borrar</span>. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.</span></p> <p class="ayuda">Todo se mueve <b>dentro de la misma carpeta</b>, y lo único que se borra son las carpetas que se quedan vacías, si las marcas tú. <span class="q" title="Más">?</span><span class="mas">Basura y proxies van a <span class="path">_Para borrar</span>. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.</span></p>
<div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div> <div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div>
<div id="revisarLleno" style="display:none"> <div id="revisarLleno" style="display:none">
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a></div> <div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a></div>
<div class="sub on" id="s-hay"> <div class="sub on" id="s-hay">
<div class="cards" id="cards"></div> <div class="cards" id="cards"></div>
@@ -400,7 +417,33 @@
</div> </div>
<div class="box" id="stockCaja" style="display:none;max-height:420px;overflow:auto"><table id="tablaStock"><thead><tr><th></th><th>Carpeta</th><th>Mes de la pieza</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div> <div class="box" id="stockCaja" style="display:none;max-height:420px;overflow:auto"><table id="tablaStock"><thead><tr><th></th><th>Carpeta</th><th>Mes de la pieza</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div>
</div> </div>
<div class="sub" id="s-vacias">
<p class="ayuda">Carpetas sin un solo archivo dentro: lo que queda cuando el material ya se movió. <b>Es lo único que la app borra</b>, y solo porque no hay nada que perder. <span class="q">?</span><span class="mas">Se comprueba en el disco antes de enseñarlas, y otra vez justo antes de borrarlas: si alguien dejó algo dentro mientras tanto, esa se salta y se avisa. <b>Deshacer</b> las vuelve a crear con su nombre. Las que son un enlace o una unión de Windows no se tocan nunca, aunque parezcan vacías. Fíjate en los nombres antes de marcar: una carpeta vacía llamada «03- Exfoliación química» es lo único que queda de esa pieza.</span></p>
<div class="toolbar">
<button id="btnVacias" class="p">Buscar carpetas vacías</button>
<button class="lnk" id="btnVaciasTodas" style="display:none">Marcar todas</button>
<span class="sep"></span><span id="vaciasResumen" class="mut"></span>
<button id="btnVaciasBorrar" class="bad" disabled>Borrar las marcadas</button><span class="por-que" id="btnVaciasBorrarPorQue">Busca primero</span>
</div> </div>
<div class="box" id="vaciasCaja" style="display:none;max-height:460px;overflow:auto"><table id="tablaVacias"><thead><tr><th></th><th>Carpeta</th><th class="num">Subcarpetas vacías dentro</th></tr></thead><tbody></tbody></table></div>
</div>
</div>
</section>
<!-- ============ MAPA ============ -->
<section id="t-mapa">
<div class="cab"><div><h2 class="v">Mapa</h2><div class="sub" id="mapaSub">cómo van a quedar las carpetas y qué va en cada una</div></div></div>
<p class="ayuda">Este árbol sale de las plantillas de verdad, así que es exactamente lo que la app va a hacer al ordenar. <span class="q">?</span><span class="mas">Las carpetas en cursiva cambian según el material (el año, el mes, el nombre de la pieza, el programa del proyecto). Las cifras son el material que tienes hoy en esta carpeta, repartido por lo que es cada archivo. Toca cualquier carpeta del árbol para ver qué guarda y cambiar dónde va.</span></p>
<div class="toolbar">
<label>tipo de trabajo <select id="mapaTipo"></select></label>
<span class="sep"></span><span id="mapaResumen" class="mut"></span>
</div>
<div class="row" style="grid-template-columns:1.35fr 1fr">
<div class="box"><h3>Cómo van a quedar las carpetas</h3><div id="mapaArbol" class="arbol mut">cargando…</div></div>
<div class="box"><h3>Qué va en esta carpeta</h3><div id="mapaDetalle" class="det mut">Toca una carpeta del árbol para ver qué guarda y cambiar dónde va.</div></div>
</div>
<div class="box" id="mapaSinSitio" style="display:none"><h3>Sin carpeta asignada</h3><div class="mut" style="font-size:12.5px;margin-bottom:6px">Este material no tiene dónde ir con este tipo de trabajo: se queda donde está. Añádele una carpeta y la app empezará a ordenarlo.</div><div id="mapaSinSitioLista"></div></div>
</section> </section>
<!-- ============ CONVERTIR ============ --> <!-- ============ CONVERTIR ============ -->
@@ -556,7 +599,8 @@ const cargarVista = seguro(async t => {
const r = cfgRaiz(); const r = cfgRaiz();
if (t !== 'carpeta' && t !== 'ajustes' && r) { try { CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raizActual())); pintarIndice(); } catch (e) { console.warn(e); } } if (t !== 'carpeta' && t !== 'ajustes' && r) { try { CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raizActual())); pintarIndice(); } catch (e) { console.warn(e); } }
if (t === 'carpeta') await cargarCarpeta(); if (t === 'carpeta') await cargarCarpeta();
if (t === 'revisar') { await cargarMapa(); await cargarVoc(); if (PLAN) pintarPlan(); if (STOCK.length && STOCK[0].raiz !== raizActual()) { STOCK = []; $('#stockCaja').style.display = 'none'; } } if (t === 'revisar') { await cargarMapa(); await cargarVoc(); if (PLAN) pintarPlan(); if (STOCK.length && STOCK[0].raiz !== raizActual()) { STOCK = []; $('#stockCaja').style.display = 'none'; } if (VACIAS && VACIAS.raiz !== raizActual()) { VACIAS = null; pintarVacias(); } }
if (t === 'mapa') await cargarMapaEst();
if (t === 'convertir') { await cargarCola(); } if (t === 'convertir') { await cargarCola(); }
if (t === 'respaldar') { if (!$('#respDestino').value) $('#respDestino').value = (ESTADO.config.respaldo || {}).destino || ''; pintarRespaldo(); } if (t === 'respaldar') { if (!$('#respDestino').value) $('#respDestino').value = (ESTADO.config.respaldo || {}).destino || ''; pintarRespaldo(); }
if (t === 'historial') { await cargarBit(); } if (t === 'historial') { await cargarBit(); }
@@ -733,6 +777,8 @@ const IR_ACCION = {
plan: p => { irA('revisar'); subRev('plan'); if (!PLAN) $('#btnPlan').click(); else if (p) { PLAN_ABIERTOS = new Set([p]); pintarPlan(); } }, plan: p => { irA('revisar'); subRev('plan'); if (!PLAN) $('#btnPlan').click(); else if (p) { PLAN_ABIERTOS = new Set([p]); pintarPlan(); } },
nombrar: () => { irA('revisar'); subRev('nombrar'); }, nombrar: () => { irA('revisar'); subRev('nombrar'); },
stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); }, stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); },
vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); },
mapa: () => irA('mapa'),
repetidos: () => { irA('revisar'); subRev('hay'); const d = $('#dups'); if (d) d.scrollIntoView({block: 'center', behavior: 'smooth'}); }, repetidos: () => { irA('revisar'); subRev('hay'); const d = $('#dups'); if (d) d.scrollIntoView({block: 'center', behavior: 'smooth'}); },
convertir: () => { irA('convertir'); $('#btnBuscarClips').click(); }, convertir: () => { irA('convertir'); $('#btnBuscarClips').click(); },
respaldar: () => irA('respaldar'), respaldar: () => irA('respaldar'),
@@ -768,6 +814,7 @@ function queSeHizo(lote){
if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por la versión convertida'; if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por la versión convertida';
if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo'; if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo';
if (lote.startsWith('dup-')) return 'Juntó material repetido'; if (lote.startsWith('dup-')) return 'Juntó material repetido';
if (lote.startsWith('vacias-')) return 'Borró carpetas vacías';
return 'Ordenó carpetas y archivos'; return 'Ordenó carpetas y archivos';
} }
function pasosDe(C, r){ function pasosDe(C, r){
@@ -1203,6 +1250,154 @@ $('#filtroCli').oninput = () => { leerAjustes(); pintarAjustes(); }; // sin le
$('#btnAddCliente').onclick = () => { leerAjustes(); AJ.clientes.unshift({nombre: '', orig: '', tipo: 'contenido', alias: '', rutas: []}); CLI_ABIERTO = 0; $('#filtroCli').value = ''; pintarAjustes(); const el = $('#clientes .cli.abierto [data-k=nombre]'); if (el) el.focus(); }; $('#btnAddCliente').onclick = () => { leerAjustes(); AJ.clientes.unshift({nombre: '', orig: '', tipo: 'contenido', alias: '', rutas: []}); CLI_ABIERTO = 0; $('#filtroCli').value = ''; pintarAjustes(); const el = $('#clientes .cli.abierto [data-k=nombre]'); if (el) el.focus(); };
$('#btnGuardarPlantillas').onclick = () => accion($('#btnGuardarPlantillas'), async () => { const plantillas = {}; $$('#plantillas input').forEach(i => { plantillas[i.dataset.t] = plantillas[i.dataset.t] || {}; plantillas[i.dataset.t][i.dataset.b] = i.value.trim(); }); await api('/api/plantillas', {plantillas}); await cargarEstado(); PLAN = null; estadoPlan(); aviso('Plantillas guardadas', 'ok'); }); $('#btnGuardarPlantillas').onclick = () => accion($('#btnGuardarPlantillas'), async () => { const plantillas = {}; $$('#plantillas input').forEach(i => { plantillas[i.dataset.t] = plantillas[i.dataset.t] || {}; plantillas[i.dataset.t][i.dataset.b] = i.value.trim(); }); await api('/api/plantillas', {plantillas}); await cargarEstado(); PLAN = null; estadoPlan(); aviso('Plantillas guardadas', 'ok'); });
// ---------------- carpetas vacías ----------------
let VACIAS = null;
const cargarVacias = seguro(async () => {
const raiz = raizActual(); if (!raiz) return;
VACIAS = await api('/api/vacias?raiz=' + encodeURIComponent(raiz));
VACIAS.carpetas.forEach(c => c.sel = false);
$('#btnVaciasTodas').textContent = 'Marcar todas';
pintarVacias();
});
function pintarVacias(){
const caja = $('#vaciasCaja'), tb = $('#tablaVacias tbody');
if (!VACIAS) { caja.style.display = 'none'; $('#btnVaciasTodas').style.display = 'none'; $('#vaciasResumen').textContent = ''; $('#btnVaciasBorrar').disabled = true; $('#btnVaciasBorrarPorQue').textContent = 'Busca primero'; return; }
const hay = VACIAS.carpetas.length;
caja.style.display = hay ? '' : 'none';
$('#btnVaciasTodas').style.display = hay ? '' : 'none';
tb.innerHTML = VACIAS.carpetas.map((c, i) => `<tr><td><input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} aria-label="${esc(c.rel)}"></td><td class="path">${esc(c.rel)}</td><td class="num">${c.subcarpetas || ''}</td></tr>`).join('');
tb.querySelectorAll('input').forEach(i => i.onchange = () => { VACIAS.carpetas[+i.dataset.i].sel = i.checked; resumenVacias(); });
resumenVacias();
}
function resumenVacias(){
const n = VACIAS ? VACIAS.carpetas.filter(c => c.sel).length : 0;
$('#vaciasResumen').textContent = !VACIAS ? '' : VACIAS.carpetas.length
? `${VACIAS.carpetas.length} vacía${VACIAS.carpetas.length > 1 ? 's' : ''} · ${n} marcada${n === 1 ? '' : 's'}`
: 'No queda ninguna carpeta vacía en esta carpeta.';
$('#btnVaciasBorrar').disabled = !n;
$('#btnVaciasBorrarPorQue').textContent = !VACIAS ? 'Busca primero' : n ? '' : 'Marca alguna';
}
$('#btnVacias').onclick = () => accion($('#btnVacias'), cargarVacias);
$('#btnVaciasTodas').onclick = () => { if (!VACIAS) return; const todas = VACIAS.carpetas.every(c => c.sel); VACIAS.carpetas.forEach(c => c.sel = !todas); $('#btnVaciasTodas').textContent = todas ? 'Marcar todas' : 'Desmarcar todas'; pintarVacias(); };
$('#btnVaciasBorrar').onclick = () => {
const s = (VACIAS ? VACIAS.carpetas : []).filter(c => c.sel); if (!s.length) return;
if (VACIAS.raiz !== raizActual()) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad');
const dentro = s.reduce((a, c) => a + (c.subcarpetas || 0), 0);
modal('Borrar carpetas vacías', `<p><b>${s.length}</b> carpeta${s.length > 1 ? 's' : ''} sin un solo archivo dentro${dentro ? `, y ${dentro} subcarpeta${dentro > 1 ? 's' : ''} igual de vacía${dentro > 1 ? 's' : ''} que cuelga${dentro > 1 ? 'n' : ''} de ella${s.length > 1 ? 's' : ''}` : ''}, se borran del disco.</p><p>No hay nada dentro que perder. Lo único que se va es el nombre, y <b>Deshacer</b> las vuelve a crear tal cual:</p><div class="ejtipo">${s.slice(0, 8).map(c => `<div><b>${esc(c.rel)}</b></div>`).join('')}${s.length > 8 ? `<div><span>… y ${s.length - 8} más</span></div>` : ''}</div>`, () => accion($('#btnVaciasBorrar'), async () => {
const r = await api('/api/vacias/borrar', {raiz: raizActual(), rutas: s.map(c => c.ruta)});
aviso(`${r.borradas} carpeta${r.borradas === 1 ? '' : 's'} vacía${r.borradas === 1 ? '' : 's'} borrada${r.borradas === 1 ? '' : 's'}.` + (r.errores.length ? ` ${r.errores.length} se saltaron.` : ''),
r.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(r.lote)});
await cargarVacias(); await cargarEstado();
}));
$('#modalSi').textContent = 'Borrar';
};
// ---------------- mapa: cómo van a quedar las carpetas ----------------
const SEP = '\\';
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {};
const cargarMapaEst = seguro(async (tipo) => {
const raiz = raizActual();
const sel = $('#mapaTipo');
if (!sel.options.length && ESTADO) sel.innerHTML = Object.entries(ESTADO.tipos).map(([k, v]) => `<option value="${k}">${esc(v)}</option>`).join('');
const q = [];
if (tipo || sel.value) q.push('tipo=' + encodeURIComponent(tipo || sel.value));
if (raiz) q.push('raiz=' + encodeURIComponent(raiz));
MAPAE = await api('/api/estructura' + (q.length ? '?' + q.join('&') : ''));
sel.value = MAPAE.tipo;
MAPAE_NODOS = {}; MAPAE_SEL = '';
anotarMapa(MAPAE.arbol, '');
pintarMapaEst();
});
function anotarMapa(n, base){
n._r = base; n._id = 'm' + Object.keys(MAPAE_NODOS).length; MAPAE_NODOS[n._id] = n;
(n.hijos || []).forEach(h => anotarMapa(h, (base ? base + SEP : '') + h.nombre));
}
function nodoHtml(n){
const cif = n.n ? `${num(n.n)} · ${gb(n.bytes)}` : '';
const que = [n.variable && ('Cambia con el material: ' + n.variable), n.nota].filter(Boolean).join('. ');
return `<div><div class="nd ${MAPAE_SEL === n._id ? 'sel' : ''} ${n.n ? '' : 'vacio'} ${n.variable ? 'var' : ''}" data-id="${n._id}" tabindex="0" role="button"${que ? ` title="${esc(que)}"` : ''}>` +
`<span class="nm">${esc(n.nombre)}${SEP}</span>` +
`${cif ? `<span class="cif">${cif}</span>` : ''}</div>` +
`${n.hijos && n.hijos.length ? `<div class="hijos">${n.hijos.map(nodoHtml).join('')}</div>` : ''}</div>`;
}
function pintarMapaEst(){
if (!MAPAE) return;
$('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo;
$('#mapaArbol').classList.remove('mut');
$('#mapaArbol').innerHTML = nodoHtml(MAPAE.arbol) +
'<div class="leyenda">Las carpetas <i>en cursiva</i> cambian con el material: el año, el mes, el nombre de la pieza o el programa. Toca cualquiera para ver qué guarda.</div>';
$$('#mapaArbol .nd').forEach(el => {
const ir = () => { MAPAE_SEL = el.dataset.id; pintarMapaEst(); };
el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } };
});
const con = MAPAE.cubetas.filter(c => c.n).length;
$('#mapaResumen').textContent = MAPAE.raiz ? `${con} de ${MAPAE.cubetas.filter(c => c.asignable).length} tipos de material tienen algo hoy en esta carpeta` : 'Conecta una carpeta para ver cuánto material tiene cada sitio';
pintarMapaDetalle();
pintarSinSitio();
}
function pintarMapaDetalle(){
const caja = $('#mapaDetalle'), n = MAPAE_NODOS[MAPAE_SEL];
if (!n) { caja.className = 'det mut'; caja.innerHTML = 'Toca una carpeta del árbol para ver qué guarda y cambiar dónde va.'; return; }
caja.className = 'det';
const cubs = (n.cubetas || []).map(c => MAPAE.cubetas.find(x => x.clave === c)).filter(Boolean);
let h = `<h4>${esc(n.nombre)}</h4><div class="ruta">${esc((MAPAE.arbol.nombre || '') + SEP + n._r)}</div>`;
if (n.variable) h += `<p class="mut" style="font-size:12.5px">El nombre cambia con el material: es ${esc(n.variable)}.</p>`;
if (n.nota) h += `<p class="mut" style="font-size:12.5px">${esc(n.nota.charAt(0).toUpperCase() + n.nota.slice(1))}.</p>`;
if (n.apps_de){
const c = MAPAE.cubetas.find(x => x.clave === n.apps_de);
if (c && c.apps.length) h += `<p style="margin:10px 0 0"><b>Una carpeta por programa:</b></p><div class="ejtipo">${c.apps.map(a => `<div><b>${esc(a.nombre)}</b><span>${num(a.n)} · ${gb(a.bytes)}</span></div>`).join('')}</div>`;
}
if (!cubs.length && !n.apps_de) h += '<p class="mut" style="font-size:12.5px">Aquí no cae nada directamente: es un nivel intermedio, lo que hay está en las carpetas de dentro.</p>';
cubs.forEach(c => {
h += `<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--line2)"><b>${esc(c.titulo)}</b>`;
h += `<ul>${c.guarda.map(g => `<li>${esc(g)}</li>`).join('')}</ul>`;
if (c.apps && c.apps.length) h += `<div class="ejtipo" style="margin-top:8px">${c.apps.map(a => `<div><b>${esc(a.nombre)}</b><span>${num(a.n)} · ${gb(a.bytes)}</span></div>`).join('')}</div>`;
h += `<p class="mut" style="font-size:12.5px;margin:8px 0 4px">${MAPAE.raiz ? (c.n ? `Hoy en esta carpeta: <b>${num(c.n)}</b> archivos · <b>${gb(c.bytes)}</b>.` : 'Hoy no tienes nada de esto en esta carpeta.') : ''}</p>`;
h += `<div class="toolbar" style="margin-top:6px"><input type="text" class="path" data-pl="${esc(c.clave)}" value="${esc(c.plantilla)}" style="flex:1;min-width:200px" aria-label="Dónde va ${esc(c.titulo)}">`;
h += `<button class="p" data-guardarpl="${esc(c.clave)}">Guardar</button>`;
if (c.defecto && c.plantilla !== c.defecto) h += `<button class="lnk" data-defecto="${esc(c.clave)}">Volver al de fábrica</button>`;
h += '</div></div>';
});
if (cubs.length) h += `<div class="vars" style="margin-top:10px">Puedes usar: ${Object.entries(MAPAE.variables).map(([k, v]) => `<div><code>{${k}}</code> — ${esc(v)}</div>`).join('')}</div>`;
caja.innerHTML = h;
engancharPlantillas(caja);
}
function pintarSinSitio(){
const sin = MAPAE.cubetas.filter(c => c.asignable && !c.plantilla);
$('#mapaSinSitio').style.display = sin.length ? '' : 'none';
if (!sin.length) return;
$('#mapaSinSitioLista').innerHTML = sin.map(c => `<div class="sinsitio"><b>${esc(c.titulo)}</b><span class="mut" style="font-size:12px">${c.n ? `${num(c.n)} · ${gb(c.bytes)}` : 'sin material hoy'}</span><input type="text" class="path" data-pl="${esc(c.clave)}" value="${esc(c.defecto)}" style="flex:1;min-width:200px" aria-label="Dónde va ${esc(c.titulo)}"><button class="p" data-guardarpl="${esc(c.clave)}">Añadir</button></div>`).join('');
engancharPlantillas($('#mapaSinSitioLista'));
}
function engancharPlantillas(caja){
caja.querySelectorAll('[data-guardarpl]').forEach(b => b.onclick = () => accion(b, async () => {
const clave = b.dataset.guardarpl;
const campo = caja.querySelector(`input[data-pl="${clave}"]`);
await guardarPlantilla(clave, campo.value.trim());
}));
caja.querySelectorAll('[data-defecto]').forEach(b => b.onclick = () => accion(b, async () => {
const c = MAPAE.cubetas.find(x => x.clave === b.dataset.defecto);
await guardarPlantilla(c.clave, c.defecto);
}));
}
async function guardarPlantilla(clave, valor){
await api('/api/plantillas', {plantillas: {[MAPAE.tipo]: {[clave]: valor}}});
PLAN = null; estadoPlan();
await cargarEstado();
// se vuelve a la MISMA carpeta por su ruta, no por su posicion: al cambiar la plantilla el arbol
// cambia de forma y el numero de nodo pasa a ser el de otra rama
const antes = (MAPAE_NODOS[MAPAE_SEL] || {})._r;
await cargarMapaEst(MAPAE.tipo);
const nodos = Object.values(MAPAE_NODOS);
// se salta a donde haya quedado lo que acabas de cambiar; si esa cubeta ya no cae en ninguna carpeta
// (la dejaste en blanco), se vuelve a la misma carpeta de antes
const vuelta = nodos.find(n => (n.cubetas || []).includes(clave)) || nodos.find(n => n._r === antes);
if (vuelta) { MAPAE_SEL = vuelta._id; pintarMapaEst(); }
aviso('Guardado. El plan se vuelve a calcular con el destino nuevo.', 'ok');
}
// accion() pinta un spinner dentro del elemento: en un <select> eso se llevaría por delante sus opciones
$('#mapaTipo').onchange = () => cargarMapaEst($('#mapaTipo').value);
// ---------------- soltar carpeta para añadir ---------------- // ---------------- soltar carpeta para añadir ----------------
const zona = $('#zonaSoltar'); const zona = $('#zonaSoltar');
['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); zona.classList.add('sobre'); $('#vacioCaja').classList.add('sobre'); })); ['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); zona.classList.add('sobre'); $('#vacioCaja').classList.add('sobre'); }));