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