2.4.0: el mapa se vuelve interactivo, con colores en Windows

- Cada carpeta lleva el color de lo que guarda (R.COLOR_CUBETA). Lo que
  cuelga de una carpeta con color hereda el suyo; una que mezcla tipos se
  queda neutra.
- «¿Dónde va a parar este archivo?»: donde_iria() resuelve cualquier nombre
  con el mismo clasificador del escaneo y el árbol enciende el destino.
  Debajo, nombres de archivo de verdad de la carpeta conectada.
- Renombrar una carpeta del árbol cambia TODAS las plantillas que pasan por
  ella (00_Grabacion arrastra a CELULAR) y, si se pide, las carpetas que ya
  existen en el disco; queda en la bitácora y se deshace.
- {anio} y {mes} dejan de encajar con cualquier carpeta: solo con algo que
  parezca un año o un mes.
- Colores en el Explorador: .ico generados sin dependencias y asignados con
  SHGetSetFolderCustomSettings, la misma API de «Cambiar icono». Escribir el
  desktop.ini a mano NO basta, y SHGetFileInfo no sirve para comprobarlo:
  no informa de la personalización ni cuando la puso el propio Explorador.
  Ruta de icono relativa, así que los colores viajan con el disco. Si el
  disco no deja marcar la carpeta, se dice y no se deja nada escrito.
- .organilab no se escanea y los desktop.ini de la app no son basura.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 22:52:05 -05:00
padre ba186d4a26
commit 1ae7a1bb38
Se han modificado 6 ficheros con 677 adiciones y 25 borrados

Ver fichero

@@ -461,6 +461,42 @@ asignada» lista el material que no tiene dónde ir con ese tipo de trabajo y de
`POST /api/plantillas` pasa a fusionar cubeta a cubeta: con `update()` de primer nivel, guardar una sola `POST /api/plantillas` pasa a fusionar cubeta a cubeta: con `update()` de primer nivel, guardar una sola
plantilla borraba las otras diecinueve. plantilla borraba las otras diecinueve.
2.4.0, el mapa deja de ser un dibujo y pasa a ser la herramienta con la que se decide el orden:
**Cada carpeta lleva el color de lo que guarda** (`R.COLOR_CUBETA`, el mismo que ya usaba el gráfico de
peso). Una carpeta que mezcla tipos de distinto color se queda neutra; lo que cuelga de una con color
hereda el suyo, así una rama entera se lee de un vistazo.
**«¿Dónde va a parar este archivo?»** Se escribe el nombre de cualquier archivo, exista o no, y la app
dice qué es, de qué cámara o programa viene, de qué fecha, y en qué carpeta lo dejaría; el árbol
enciende el destino. Lo resuelve `donde_iria()` con el mismo clasificador que usa el escaneo, así que
no es una simulación aparte. Debajo salen nombres de archivo de verdad de la carpeta conectada para
probar con un clic. `GET /api/estructura/archivo`.
**Cambiarle el nombre a una carpeta.** Se toca una carpeta del árbol, se escribe otro nombre y el
cambio alcanza a **todas** las plantillas que pasan por ahí: renombrar `00_Grabacion` cambia también
`CELULAR`, que cuelga de ella. Si ya hay carpetas con ese nombre en el disco, dice cuántas y ofrece
renombrarlas también; es un movimiento normal, queda en la bitácora y se deshace desde Historial. Las
carpetas que nombra el material (el año, el mes, la pieza) no se pueden renombrar a mano, y los
nombres imposibles se rechazan. `POST /api/estructura/renombrar`, `GET /api/estructura/existen`.
De paso, `{anio}` y `{mes}` dejan de encajar con cualquier carpeta: solo con algo que de verdad parezca
un año o un mes («Efectos» salía como si fuera un año).
**Colores en el Explorador de Windows.** Windows no tiene «color de carpeta», pero sí icono por
carpeta. La app genera un `.ico` por color sin dependencias (el mismo codificador PNG/ICO del logo) en
`.organilab\iconos` dentro de la propia carpeta del cliente, y lo asigna con
`SHGetSetFolderCustomSettings`, que es la puerta que usa «Propiedades › Personalizar › Cambiar icono»:
escribe el `desktop.ini` como lo escribe Windows, marca la carpeta y refresca la caché de iconos.
Escribir el `desktop.ini` a mano no bastaba, y comprobarlo con `SHGetFileInfo` engaña, porque esa vía
no informa de la personalización ni cuando la ha puesto el propio Explorador; la comprobación buena es
releer con la misma API. La ruta del icono es relativa, así que los colores viajan con el disco.
Medido el 16-sep-2026: en C: se puede, en Z: solo con el atributo de sistema, y en W: el disco no lo
permite — en ese caso la app lo dice y no deja nada escrito. Se quita con un clic.
`POST /api/colores/pintar` y `/api/colores/quitar`.
La carpeta `.organilab` no se escanea ni se ordena, y los `desktop.ini` que pone la app no se cuentan
como basura: si no, el primer «apartar basura» se llevaba los colores por delante.
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.3.0", "version": "2.4.0",
"builtAt": "2026-09-17T03:31:56.000Z", "builtAt": "2026-09-17T03:49:29.000Z",
"archivo": "OrganizadorLabstream-portatil-2.3.0.zip", "archivo": "OrganizadorLabstream-portatil-2.4.0.zip",
"size": 198716642, "size": 198734267,
"sha256": "1fe85cae6053dca7b0b381312fcac570d4595a78b7fd55254662f61d2be8521b", "sha256": "02a9dade711cab28953b8e110d3440e4c2262287354e20b0d99ea00e05198b8f",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-2.3.0.zip", "archivo": "OrganizadorLabstream-app-2.4.0.zip",
"size": 14896889, "size": 14914514,
"sha256": "731ea6a9477d0bf2c04cc26a8a92fe6615e5e45ed1313d43020de16c7599ca70", "sha256": "fc5e250afb755748b3c8a227780fef44c0b231026087247328e7d723cb87135a",
"url": "/api/herramientas/OrganizadorLabstream-app-2.3.0.zip" "url": "/api/herramientas/OrganizadorLabstream-app-2.4.0.zip"
}, },
"notes": "", "notes": "",
"firma": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "bd0526e1cb3db8a7a6f8ecd0ddac54d627dee221713adba628eb6bbde1e601952770aebcc8ca07c379047af960117c0403cb12b19d80bf8a61a607d330365c3facd34d49b6c615e7cd6c1bd92eb9878f0961fa7f85953d235368abdf8704a12066ed6b31f2588201ef175fef264db0c14b318f50b587afbe73246405cbe423fe403376e00ff7610f89948806169ea3e13cd4aaf9b30565bba184083b11e9e49c01157b340d2e019bc23aed6aaa7b992e5ec7a2408c476352c603b6e5c5601a282d9e0eb6e724036c01b12f06011e23d538dae83ded019475800cfe62876b3a6d42b23f4fbadd1371b5b039328a44e33a8cadb94e630c39a30883b33a78b2d3ab" "valor": "41707cfc81c53d0f2aa3c2cd2180c49fa588beef835d159f4caf60ef9a3e32aefa56a02f29143867eba7902f1ddf71c9a92332911ee617367be5634daff2eb293bf9a11ab668e827e0012159785b7a1d0f37ac8d76ad588f9d1521bf89c0d2539e6a6812d47fb7beae027493f6b53f844eaa655c81415396413031bd8aff59a49a5592e9263af442a7c853e17a63cb6b274e12f88d2066295ed0f6b3a030a938921e513e539b6fbe968348de5618b6421b48d5b72758c8adef2cdaeec768bf75aa45fecd390392e693038a600dd051f62aa47a62910f5ded9601ba0a3f402feb0f9389b3a27786a1b6e7ca5f7f57c21f93e80d09ecd675b9c3200241ccb7aea4"
} }
} }

Ver fichero

@@ -14,6 +14,9 @@ import os
import sys import sys
import re import re
import json import json
import zlib
import struct
import ctypes
import time import time
import queue import queue
import shutil import shutil
@@ -34,7 +37,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.3.0" VERSION = "2.4.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
@@ -437,8 +440,8 @@ def escanear(ruta):
try: try:
nombre = e.name nombre = e.name
if e.is_dir(follow_symlinks=False): if e.is_dir(follow_symlinks=False):
if nombre.lower() in ("#recycle", "@eadir"): if nombre.lower() in ("#recycle", "@eadir", R.CARPETA_APP):
continue continue # lo de la app (sus iconos de color) no es material del cliente
p = en_proxy or R.es_proxy_dir(nombre) p = en_proxy or R.es_proxy_dir(nombre)
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM
# una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro: # una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
@@ -461,6 +464,8 @@ def escanear(ruta):
continue continue
walk(e.path, p, b, s, c2, n2, a2, m2, vistos) walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
else: else:
if nombre.lower() == "desktop.ini" and _ini_nuestro(e.path):
continue # el color de la carpeta lo puso la app: ni se indexa ni se aparta
st = e.stat(follow_symlinks=False) st = e.stat(follow_symlinks=False)
ext = os.path.splitext(nombre)[1].lower() ext = os.path.splitext(nombre)[1].lower()
firma = R.firma_archivo(nombre) firma = R.firma_archivo(nombre)
@@ -2898,19 +2903,32 @@ def mapa_estructura(tipo, raiz=None):
apps[a][1] += f["b"] apps[a][1] += f["b"]
con.close() con.close()
# nombres de archivo de verdad, para poder probar el mapa con lo que hay en el disco
ejemplos = defaultdict(list)
if raiz:
con = db()
for f in con.execute("SELECT rol, nombre, MAX(bytes) b FROM archivos WHERE raiz = ? AND es_basura = 0 "
"AND es_proxy = 0 GROUP BY rol, ext ORDER BY b DESC", (raiz,)).fetchall():
cub = R.ROL_A_CUBETA.get(f["rol"], "REVISAR")
if len(ejemplos[cub]) < 4:
ejemplos[cub].append(f["nombre"])
con.close()
# cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta # cubetas -> roles que caen en ellas, para poder decir qué guarda cada carpeta
roles = defaultdict(list) roles = defaultdict(list)
for rol, cub in R.ROL_A_CUBETA.items(): for rol, cub in R.ROL_A_CUBETA.items():
roles[cub].append(R.ROL_QUE.get(rol, rol)) 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", 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} "hijos": [], "cubetas": [], "variable": "", "nota": "", "crudo": [], "color": "",
"n": 0, "bytes": 0}
def bajar(nodo, nombre, variable): def bajar(nodo, nombre, variable, crudo):
for h in nodo["hijos"]: for h in nodo["hijos"]:
if h["nombre"].lower() == nombre.lower(): if h["nombre"].lower() == nombre.lower():
return h return h
h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "", "n": 0, "bytes": 0} h = {"nombre": nombre, "hijos": [], "cubetas": [], "variable": variable, "nota": "",
"crudo": list(crudo), "color": "", "n": 0, "bytes": 0}
nodo["hijos"].append(h) nodo["hijos"].append(h)
return h return h
@@ -2920,16 +2938,19 @@ def mapa_estructura(tipo, raiz=None):
n, b = hay.get(cub, [0, 0]) n, b = hay.get(cub, [0, 0])
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla, cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
"asignable": cub not in ("CONTENEDOR", "IGNORAR"), "asignable": cub not in ("CONTENEDOR", "IGNORAR"),
"color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
"ejemplos": ejemplos.get(cub, []),
"defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""), "defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""),
"guarda": sorted(set(roles.get(cub, []))), "n": n, "bytes": b, "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()), "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 [])}) key=lambda x: -x["bytes"]) if cub == "PROYECTOS" else [])})
if not plantilla: if not plantilla:
continue continue
nodo = raiz_nodo nodo, crudo = raiz_nodo, []
for seg in plantilla.split("/"): for seg in plantilla.split("/"):
if seg == "{app}": if seg == "{app}":
nodo = bajar(nodo, EJEMPLO_VAR["app"][0], EJEMPLO_VAR["app"][1]) crudo.append(seg)
nodo = bajar(nodo, EJEMPLO_VAR["app"][0], EJEMPLO_VAR["app"][1], crudo)
nodo["nota"] = ("solo cuando el proyecto va suelto; si ya viene dentro de su propia " nodo["nota"] = ("solo cuando el proyecto va suelto; si ya viene dentro de su propia "
"carpeta, se queda con el nombre de esa carpeta") "carpeta, se queda con el nombre de esa carpeta")
nodo["apps_de"] = cub # aquí es donde se busca «¿cuánto tengo de cada programa?» nodo["apps_de"] = cub # aquí es donde se busca «¿cuánto tengo de cada programa?»
@@ -2943,7 +2964,8 @@ def mapa_estructura(tipo, raiz=None):
if "{" + k + "}" in texto: if "{" + k + "}" in texto:
texto = texto.replace("{" + k + "}", ej) texto = texto.replace("{" + k + "}", ej)
hint = que if not hint else hint + " y " + que hint = que if not hint else hint + " y " + que
nodo = bajar(nodo, texto, hint) crudo.append(seg)
nodo = bajar(nodo, texto, hint, crudo)
nodo["cubetas"].append(cub) nodo["cubetas"].append(cub)
nodo["n"] += n nodo["n"] += n
nodo["bytes"] += b nodo["bytes"] += b
@@ -2951,14 +2973,424 @@ def mapa_estructura(tipo, raiz=None):
def ordenar(nodo): def ordenar(nodo):
# una carpeta con nombre fijo antes que una variable, y dentro de cada grupo por nombre # 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())) nodo["hijos"].sort(key=lambda h: (bool(h["variable"]), h["nombre"].lower()))
cols = {R.COLOR_CUBETA.get(c, "") for c in nodo["cubetas"]}
for h in nodo["hijos"]: for h in nodo["hijos"]:
ordenar(h) ordenar(h)
nodo["n"] += h["n"] nodo["n"] += h["n"]
nodo["bytes"] += h["bytes"] nodo["bytes"] += h["bytes"]
cols.add(h["color"])
cols.discard("")
# una carpeta toma el color de lo que guarda; si mezcla tipos de distinto color, se queda neutra
nodo["color"] = cols.pop() if len(cols) == 1 else ""
ordenar(raiz_nodo) ordenar(raiz_nodo)
return {"tipo": tipo, "titulo": R.TIPOS_CLIENTE.get(tipo, tipo), "raiz": raiz or "", "cliente": cliente, 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()}} "arbol": raiz_nodo, "cubetas": cubetas, "variables": {k: v[1] for k, v in EJEMPLO_VAR.items()},
"colores": R.COLOR_CUBETA, "pintado": _esta_pintada(raiz) if raiz else False}
def donde_iria(nombre, tipo="contenido"):
"""Qué es un archivo y en qué carpeta lo dejaría la app, solo por su nombre.
Es el mapa al revés: en vez de «esta carpeta guarda esto», «esto va a esta carpeta». Vale para
cualquier nombre, exista o no, porque lo decide el mismo clasificador que usa el escaneo."""
nombre = (nombre or "").strip().strip('"').replace("/", os.sep).split(os.sep)[-1]
if not nombre:
return {"error": "escribe el nombre de un archivo"}
ext = os.path.splitext(nombre)[1].lower()
firma = R.firma_archivo(nombre)
rol = R.rol_archivo(nombre, ext, firma)
cub = R.ROL_A_CUBETA.get(rol, "REVISAR")
fecha, origen = R.fecha_de_nombre(nombre, time.time())
kw = {"anio": int(fecha[:4]), "mes": int(fecha[5:7])}
if cub == "GRABACION":
kw["sub"] = R.FUENTE_CARPETA.get(firma, "OTROS")
if cub == "CELULAR" and tipo == "contenido":
cub, kw["sub"] = "GRABACION", "CELULAR"
if cub == "PROYECTOS":
kw["app"] = R.app_proyecto(ext) or ("Diseño" if rol == "diseno" else "Edicion")
destino = "_Revisar" if cub == "REVISAR" else destino_plantilla(tipo, cub, **kw)
return {"nombre": nombre, "rol": rol, "que": R.ROL_QUE.get(rol, rol), "cubeta": cub,
"titulo": R.CUBETAS.get(cub, cub), "color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
"destino": destino, "crudo": [x for x in (PLANTILLAS.get(tipo, {}).get(cub, "") or "").split("/") if x],
"fecha": fecha, "por_fecha": origen, "camara": firma or "",
"app": R.app_proyecto(ext) or ""}
def renombrar_nodo(tipo, ruta, nombre, raiz=None, en_disco=False):
"""Le cambia el nombre a una carpeta del mapa: en las plantillas y, si se pide, en el disco.
`ruta` son los segmentos CRUDOS del mapa («{anio}», «{mes}», «00_Grabacion»), así que el cambio
alcanza a todas las plantillas que pasan por esa carpeta, no solo a una."""
tipo = tipo if tipo in PLANTILLAS else "contenido"
nombre = (nombre or "").strip().strip(". ")
if not nombre:
return {"error": "el nombre no puede quedar vacío"}
if any(c in nombre for c in '\\/:*?"<>|'):
return {"error": 'un nombre de carpeta no puede llevar \\ / : * ? " < > |'}
if "{" in nombre or "}" in nombre:
return {"error": "aquí va un nombre, no una variable: las variables se editan en la plantilla"}
if not ruta or "{" in ruta[-1]:
return {"error": "esa carpeta la nombra el material (el año, el mes, la pieza): no se puede cambiar a mano"}
viejo = ruta[-1]
if R.norm(viejo) == R.norm(nombre):
return {"error": "ya se llama así"}
cambiadas = []
for cub, pl in list(PLANTILLAS[tipo].items()):
segs = pl.split("/")
if len(segs) >= len(ruta) and [x.lower() for x in segs[:len(ruta)]] == [x.lower() for x in ruta]:
segs[len(ruta) - 1] = nombre
PLANTILLAS[tipo][cub] = "/".join(segs)
cambiadas.append(cub)
if not cambiadas:
return {"error": "esa carpeta ya no está en ninguna plantilla; vuelve a abrir el mapa"}
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
PLANES.clear()
movidas, errores = 0, []
if en_disco and raiz:
movidas, errores = _renombrar_en_disco(_clave(raiz), ruta, nombre)
return {"ok": True, "cubetas": cambiadas, "viejo": viejo, "nuevo": nombre,
"movidas": movidas, "errores": errores}
def _casan(segmentos, patron):
"""True si los segmentos de una carpeta real encajan con un patron de plantilla.
«{anio}» y «{mes}» solo valen por algo que de verdad parezca un ano o un mes: sin eso, «{anio}»
encajaba con cualquier carpeta del primer nivel («Efectos» salia como si fuera un ano)."""
if len(segmentos) != len(patron):
return False
for g, p in zip(segmentos, patron):
if p == "{anio}":
if not R.detectar_anio(g):
return False
elif p == "{mes}":
if not R.detectar_mes(g)[0]:
return False
elif p.startswith("{"):
continue
elif R.norm(p) != R.norm(g):
return False
return True
def carpetas_como(raiz, ruta):
"""Las carpetas que hay HOY en el disco en el sitio que describe `ruta` (segmentos crudos)."""
raiz = _clave(raiz)
con = db()
filas = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
con.close()
out = []
for c in filas:
rel = os.path.relpath(c, raiz)
if rel == "." or not os.path.isdir(c):
continue
if _casan(rel.split(os.sep), ruta):
out.append(c)
return sorted(out, key=lambda x: x.lower())
def _renombrar_en_disco(raiz, ruta, nombre):
"""Renombra en el disco todas las carpetas que estaban en ese sitio del mapa. Queda en la bitácora
como un movimiento normal, así que se deshace desde Historial."""
lote = "renombrar-" + time.strftime("%Y%m%d-%H%M%S")
movidos, errores = [], []
for c in carpetas_como(raiz, ruta):
destino = os.path.join(os.path.dirname(c), nombre)
if os.path.normcase(c) == os.path.normcase(destino):
continue
try:
mover(c, destino, lote, movidos)
except Exception as ex: # noqa
errores.append(f"{os.path.relpath(c, raiz)}: {ex}")
if movidos:
actualizar_inventario(movidos)
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos),
"movimientos": len(movidos), "errores": errores})
return len(movidos), errores
# ==========================================================================
# Color de carpeta en el Explorador de Windows
# ==========================================================================
#
# Windows no tiene «color de carpeta», pero sí icono por carpeta: un desktop.ini que apunta a un .ico
# y la carpeta marcada como solo lectura para que el Explorador lo lea. La app genera un icono por
# color dentro de la propia carpeta del cliente y lo referencia con ruta relativa, así que los colores
# viajan con el disco y se ven igual desde cualquier PC que lo tenga montado.
F_READONLY, F_HIDDEN, F_SYSTEM = 0x1, 0x2, 0x4
FCSM_ICONFILE, FCS_READ, FCS_FORCEWRITE = 0x10, 0x1, 0x2
SHCNE_UPDATEDIR, SHCNF_PATHW = 0x1000, 0x0005
class _FCS(ctypes.Structure):
"""SHFOLDERCUSTOMSETTINGS: lo que hay en el desktop.ini de una carpeta, visto por el Explorador."""
_fields_ = [("dwSize", ctypes.c_uint32), ("dwMask", ctypes.c_uint32),
("pvid", ctypes.c_void_p), ("pszWebViewTemplate", ctypes.c_wchar_p),
("cchWebViewTemplate", ctypes.c_uint32), ("pszWebViewTemplateVersion", ctypes.c_wchar_p),
("pszInfoTip", ctypes.c_wchar_p), ("cchInfoTip", ctypes.c_uint32),
("pclsid", ctypes.c_void_p), ("dwFlags", ctypes.c_uint32),
("pszIconFile", ctypes.c_wchar_p), ("cchIconFile", ctypes.c_uint32),
("iIconIndex", ctypes.c_int), ("pszLogo", ctypes.c_wchar_p), ("cchLogo", ctypes.c_uint32)]
def _fcs():
f = ctypes.windll.shell32.SHGetSetFolderCustomSettings
f.argtypes = [ctypes.POINTER(_FCS), ctypes.c_wchar_p, ctypes.c_uint32]
f.restype = ctypes.c_long
return f
def _poner_icono(carpeta, icono):
"""Le pone a la carpeta ese .ico (la ruta puede ser relativa, y así los colores viajan con el disco)."""
w = _FCS()
w.dwSize = ctypes.sizeof(_FCS)
w.dwMask = FCSM_ICONFILE
w.pszIconFile = icono
w.iIconIndex = 0
try:
if _fcs()(ctypes.byref(w), carpeta, FCS_FORCEWRITE) != 0:
return False
ctypes.windll.shell32.SHChangeNotify(SHCNE_UPDATEDIR, SHCNF_PATHW, ctypes.c_wchar_p(carpeta), None)
return True
except Exception: # noqa
return False
def _icono_de(carpeta):
"""El .ico que tiene puesto la carpeta, o "" si no tiene ninguno."""
buf = ctypes.create_unicode_buffer(600)
r = _FCS()
r.dwSize = ctypes.sizeof(_FCS)
r.dwMask = FCSM_ICONFILE
r.pszIconFile = ctypes.cast(buf, ctypes.c_wchar_p)
r.cchIconFile = 600
try:
return buf.value if _fcs()(ctypes.byref(r), carpeta, FCS_READ) == 0 else ""
except Exception: # noqa
return ""
def _ini_nuestro(ruta):
"""Un desktop.ini es de la app si apunta a un icono de su propia carpeta."""
try:
with open(ruta, "rb") as fh:
return R.CARPETA_APP.encode() in fh.read(1024).lower()
except OSError:
return False
def _atributos(ruta, poner=0, quitar=0):
"""Pone o quita atributos y devuelve si de verdad quedaron puestos.
En un NAS por SMB, SetFileAttributesW dice que sí y el atributo no queda: medido el 16-sep-2026,
Z: acepta SYSTEM pero no READONLY, y W: no acepta ninguno de los dos. Por eso se relee."""
try:
a = ctypes.windll.kernel32.GetFileAttributesW(str(ruta))
if a == -1:
return False
ctypes.windll.kernel32.SetFileAttributesW(str(ruta), (a | poner) & ~quitar)
if not poner:
return True
b = ctypes.windll.kernel32.GetFileAttributesW(str(ruta))
return b != -1 and bool(b & poner)
except Exception: # noqa
return False
def _marcar_carpeta(carpeta):
"""El Explorador solo lee desktop.ini si la carpeta está marcada como solo lectura O como sistema.
Vale cualquiera de las dos: se intentan las dos y basta con que una quede."""
return (_atributos(carpeta, poner=F_READONLY) or _atributos(carpeta, poner=F_SYSTEM))
def _png_carpeta(n, rgb):
"""Una carpeta del color pedido, n×n RGBA, con la pestaña un punto más oscura."""
u = n / 32.0
base = rgb
tab = tuple(int(c * 0.78) for c in rgb)
def rect(px, py, x0, y0, x1, y1, r):
if not (x0 <= px <= x1 and y0 <= py <= y1):
return False
cx = min(max(px, x0 + r), x1 - r)
cy = min(max(py, y0 + r), y1 - r)
return (px - cx) ** 2 + (py - cy) ** 2 <= r * r
filas = []
for y in range(n):
fila = bytearray()
for x in range(n):
dentro_b = dentro_t = 0
for sx, sy in ((.25, .25), (.75, .25), (.25, .75), (.75, .75)):
px, py = (x + sx) / u, (y + sy) / u
if rect(px, py, 2, 8.5, 30, 27, 2.2):
dentro_b += 1
elif rect(px, py, 2, 5, 14.5, 11, 1.4):
dentro_t += 1
if dentro_b:
a, col = dentro_b / 4, base
elif dentro_t:
a, col = dentro_t / 4, tab
else:
fila += bytes((0, 0, 0, 0))
continue
fila += bytes((col[0], col[1], col[2], int(255 * a)))
filas.append(bytes(fila))
raw = b"".join(b"\x00" + f for f in filas)
def chunk(t, d):
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
def _ico_carpeta(hexcol):
rgb = tuple(int(hexcol.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
tam = (256, 48, 32, 16)
imgs = [_png_carpeta(t, rgb) for t in tam]
cab = struct.pack("<HHH", 0, 1, len(tam))
off = 6 + 16 * len(tam)
entradas = b""
for t, im in zip(tam, imgs):
entradas += struct.pack("<BBBBHHII", t % 256, t % 256, 0, 0, 1, 32, len(im), off)
off += len(im)
return cab + entradas + b"".join(imgs)
def _dir_iconos(raiz):
return os.path.join(raiz, R.CARPETA_APP, "iconos")
def _esta_pintada(raiz):
return os.path.isdir(_dir_iconos(raiz))
def _nodos_pintables(nodo, out=None):
"""(segmentos crudos, color) de cada carpeta del mapa que tiene color, las más concretas primero:
«{anio}/{mes}/{nombre}» también encaja con «00_Grabacion», y ahí manda la que tiene nombre fijo."""
out = [] if out is None else out
if nodo.get("crudo") and nodo.get("color"):
out.append((nodo["crudo"], nodo["color"]))
for h in nodo.get("hijos", []):
_nodos_pintables(h, out)
out.sort(key=lambda x: -sum(0 if seg.startswith("{") else 1 for seg in x[0]))
return out
def _carpetas_a_pintar(raiz):
"""Cada carpeta real del cliente con el color de lo que guarda."""
mapa = mapa_estructura((raiz_cfg(raiz) or {}).get("tipo", "contenido"), raiz)
patrones = _nodos_pintables(mapa["arbol"])
con = db()
reales = [r["ruta"] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
con.close()
out = {}
for c in reales:
rel = os.path.relpath(c, raiz)
if rel == "." or rel.split(os.sep)[0].lower() == R.CARPETA_APP:
continue
segs = rel.split(os.sep)
for crudo, color in patrones:
if _casan(segs, crudo):
out[c] = color
break
# lo que cuelga de una carpeta con color es más de lo mismo («URSA_BRAW» dentro de «00_Grabacion»):
# hereda su color, y así en el Explorador una rama entera se ve de un color
for c in sorted(reales, key=lambda x: len(x)):
if c in out or os.path.relpath(c, raiz) == ".":
continue
padre = os.path.dirname(c)
while padre.lower().startswith(raiz.lower()) and padre.lower() != raiz.lower():
if padre in out:
out[c] = out[padre]
break
padre = os.path.dirname(padre)
return out
def pintar_carpetas(raiz):
"""Le pone a cada carpeta el icono del color de lo que guarda."""
raiz = _clave(raiz)
if not os.path.isdir(raiz):
return {"error": "la carpeta no está conectada"}
objetivo = _carpetas_a_pintar(raiz)
if not objetivo:
return {"error": "no hay ninguna carpeta reconocida todavía: escanea y ordena primero"}
iconos = _dir_iconos(raiz)
try:
os.makedirs(iconos, exist_ok=True)
_atributos(os.path.join(raiz, R.CARPETA_APP), poner=F_HIDDEN)
except OSError as ex:
return {"error": f"no se pudo crear la carpeta de iconos: {ex}"}
for color in set(objetivo.values()):
ico = os.path.join(iconos, color.lstrip("#").lower() + ".ico")
if not os.path.exists(ico):
with open(ico, "wb") as fh:
fh.write(_ico_carpeta(color))
pintadas, sin_marca, errores = 0, 0, []
for carpeta, color in sorted(objetivo.items()):
try:
rel = os.path.join(os.path.relpath(iconos, carpeta), color.lstrip("#").lower() + ".ico")
_atributos(os.path.join(carpeta, "desktop.ini"), quitar=F_READONLY)
if not _poner_icono(carpeta, rel):
sin_marca += 1
continue
# la API marca la carpeta ella sola, pero algunos discos de red no dejan: se comprueba
if _icono_de(carpeta).lower() == rel.lower() and _marcar_carpeta(carpeta):
pintadas += 1
else:
sin_marca += 1
except OSError as ex:
if len(errores) < 5:
errores.append(f"{os.path.relpath(carpeta, raiz)}: {ex}")
if not pintadas and sin_marca:
# el disco no deja marcar las carpetas: el desktop.ini se quedaría ahí sin hacer nada
despintar_carpetas(raiz)
return {"error": "este disco no deja marcar las carpetas, y sin esa marca el Explorador ni mira el "
"color. Pasa en algunos NAS y discos de red. No se ha dejado nada escrito.",
"pintadas": 0, "errores": []}
try:
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None) # SHCNE_ASSOCCHANGED
except Exception: # noqa
pass
bitacora({"lote": "colores-" + time.strftime("%Y%m%d-%H%M%S"), "op": "colores", "raiz": raiz,
"pintadas": pintadas, "errores": errores})
return {"pintadas": pintadas, "sin_marca": sin_marca, "colores": len(set(objetivo.values())),
"errores": errores}
def despintar_carpetas(raiz):
"""Quita los colores: borra los desktop.ini que puso la app y su carpeta de iconos."""
raiz = _clave(raiz)
quitadas, errores = 0, []
for base, dirs, files in os.walk(raiz):
if os.path.basename(base).lower() == R.CARPETA_APP:
dirs[:] = []
continue
for f in files:
if f.lower() != "desktop.ini":
continue
ini = os.path.join(base, f)
if not _ini_nuestro(ini):
continue
try:
_atributos(ini, quitar=F_READONLY | F_HIDDEN | F_SYSTEM)
os.remove(ini)
_atributos(base, quitar=F_READONLY)
quitadas += 1
except OSError as ex:
if len(errores) < 5:
errores.append(f"{os.path.relpath(base, raiz)}: {ex}")
try:
shutil.rmtree(os.path.join(raiz, R.CARPETA_APP), ignore_errors=True)
ctypes.windll.shell32.SHChangeNotify(0x08000000, 0x0000, None, None)
except Exception: # noqa
pass
return {"quitadas": quitadas, "errores": errores}
def acciones(raiz): def acciones(raiz):
@@ -3308,6 +3740,16 @@ class Manejador(BaseHTTPRequestHandler):
return self._json(papelera(os.path.normpath(q["raiz"]))) return self._json(papelera(os.path.normpath(q["raiz"])))
if ruta == "/api/vacias": if ruta == "/api/vacias":
return self._json(carpetas_vacias(os.path.normpath(q["raiz"]))) return self._json(carpetas_vacias(os.path.normpath(q["raiz"])))
if ruta == "/api/estructura/archivo":
return self._json(donde_iria(q.get("nombre", ""), q.get("tipo") or "contenido"))
if ruta == "/api/estructura/existen":
try:
segs = json.loads(q.get("ruta") or "[]")
except ValueError:
segs = []
cs = carpetas_como(os.path.normpath(q["raiz"]), segs) if segs else []
base = os.path.normpath(q["raiz"])
return self._json({"n": len(cs), "carpetas": [os.path.relpath(c, base) for c in cs[:20]]})
if ruta == "/api/estructura": if ruta == "/api/estructura":
raiz = q.get("raiz") raiz = q.get("raiz")
tipo = q.get("tipo") tipo = q.get("tipo")
@@ -3500,6 +3942,13 @@ 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/estructura/renombrar":
return self._json(renombrar_nodo(d.get("tipo") or "contenido", d.get("ruta") or [],
d.get("nombre", ""), d.get("raiz"), bool(d.get("en_disco"))))
if ruta == "/api/colores/pintar":
return self._json(pintar_carpetas(os.path.normpath(d["raiz"])))
if ruta == "/api/colores/quitar":
return self._json(despintar_carpetas(os.path.normpath(d["raiz"])))
if ruta == "/api/vacias/borrar": if ruta == "/api/vacias/borrar":
return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", []))) return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", [])))
if ruta == "/api/duplicados/apartar": if ruta == "/api/duplicados/apartar":

Ver fichero

@@ -233,6 +233,56 @@ class Servidor(unittest.TestCase):
self.assertEqual(self.post("/api/plantillas", {"plantillas": {"contenido": {"MUSICA": "_Recursos/Musica/{sub}"}}})[0], 200) 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) self.assertEqual(len([c for c in self.get("/api/estructura" + q)["cubetas"] if c["plantilla"]]), antes_pl)
# 4b-bis. el mapa al revés: dónde iría un archivo concreto
def donde(nombre):
return self.get("/api/estructura/archivo?tipo=contenido&nombre=" + urllib.parse.quote(nombre))
d = donde("A006_12171629_C001.braw")
self.assertEqual(d["cubeta"], "GRABACION")
self.assertIn("URSA_BRAW", d["destino"])
self.assertIn("12- Diciembre", d["destino"]) # la fecha sale del nombre de la cámara
self.assertEqual(donde("Proyecto.aep")["destino"], os.path.join("01_Proyectos", "After Effects"))
self.assertEqual(donde("cancion.wav")["cubeta"], "MUSICA")
self.assertTrue(donde("")["error"])
# 4b-ter. renombrar una carpeta del mapa cambia TODAS las plantillas que pasan por ella,
# y las carpetas que ya existen en el disco si se pide
os.makedirs(os.path.join(self.raiz, "2026", "03- Marzo", "00_Grabacion"), exist_ok=True)
self.post("/api/escanear", {"raiz": self.raiz})
self.esperar_escaneo()
crudo = ["{anio}", "{mes}", "00_Grabacion"]
ex = self.get("/api/estructura/existen?raiz=" + urllib.parse.quote(self.raiz)
+ "&ruta=" + urllib.parse.quote(json.dumps(crudo)))
self.assertEqual(ex["n"], 1)
# «{anio}» solo vale por algo que parezca un año
self.assertEqual(self.get("/api/estructura/existen?raiz=" + urllib.parse.quote(self.raiz)
+ "&ruta=" + urllib.parse.quote(json.dumps(["{anio}"])))["n"], 1)
c, r = self.post("/api/estructura/renombrar", {"tipo": "contenido", "ruta": crudo,
"nombre": "Grabacion", "raiz": self.raiz, "en_disco": True})
self.assertEqual(c, 200)
self.assertTrue(r.get("ok"), r)
self.assertEqual(r["movidas"], 1)
self.assertIn("CELULAR", r["cubetas"]) # la de celular cuelga de la misma carpeta
self.assertTrue(os.path.isdir(os.path.join(self.raiz, "2026", "03- Marzo", "Grabacion")))
for malo in ("", "con/barra", "{anio}"):
self.assertTrue(self.post("/api/estructura/renombrar",
{"tipo": "contenido", "ruta": crudo, "nombre": malo})[1].get("error"))
shutil.rmtree(os.path.join(self.raiz, "2026"), ignore_errors=True)
# 4b-quáter. colores de carpeta en Windows: se ponen y se quitan sin tocar ningún archivo
self.post("/api/escanear", {"raiz": self.raiz})
self.esperar_escaneo()
arbol_color = self._arbol()
c, col = self.post("/api/colores/pintar", {"raiz": self.raiz})
self.assertEqual(c, 200)
if not col.get("error"): # algunos discos no lo permiten: es válido
self.assertGreater(col["pintadas"], 0)
self.assertTrue(os.path.isdir(os.path.join(self.raiz, R.CARPETA_APP, "iconos")))
self.assertEqual(arbol_color, self._arbol()) # ni un archivo del cliente tocado
c, q = self.post("/api/colores/quitar", {"raiz": self.raiz})
self.assertEqual(q["quitadas"], col["pintadas"])
self.assertFalse(os.path.exists(os.path.join(self.raiz, R.CARPETA_APP)))
self.assertEqual(arbol_color, self._arbol())
# 4c. carpetas vacías: se encuentran, se borran sin tocar un archivo y deshacer las devuelve # 4c. carpetas vacías: se encuentran, se borran sin tocar un archivo y deshacer las devuelve
for rel in ("Huecos/uno", "Huecos/dos", "Sola"): for rel in ("Huecos/uno", "Huecos/dos", "Sola"):
os.makedirs(os.path.join(self.raiz, rel.replace("/", os.sep)), exist_ok=True) os.makedirs(os.path.join(self.raiz, rel.replace("/", os.sep)), exist_ok=True)

Ver fichero

@@ -615,9 +615,23 @@ DUPLICADOS = "Duplicados"
CARPETAS_PROPIAS = {norm(x) for x in ( CARPETAS_PROPIAS = {norm(x) for x in (
"_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "_Apoyos", "_Documentos", "00_Marca", "01_Proyectos", "Campañas", "_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "_Apoyos", "_Documentos", "00_Marca", "01_Proyectos", "Campañas",
"_Exportados", "_Portadas", "00_Grabacion", "_Voces", "_IA", "_Guiones", "_Fotos", "_Musica", "_Archivo", "_Exportados", "_Portadas", "00_Grabacion", "_Voces", "_IA", "_Guiones", "_Fotos", "_Musica", "_Archivo",
"00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados")} "00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados", ".organilab")}
# 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)
# Un color por tipo de material. Se usa en el mapa, en el gráfico de peso y —si se quiere— en el
# propio Explorador de Windows, poniéndole a cada carpeta un icono de ese color.
COLOR_CUBETA = {
"ANIO": "#4f8cff", "GRABACION": "#e0574f", "PIEZA": "#7c5cff", "CAMPANA": "#ff8c42",
"EXPORTADOS": "#3ecf8e", "PORTADAS": "#f5b84b", "APOYOS": "#d96fd9", "MARCA": "#39b8c9",
"PROYECTOS": "#8fa1b8", "MUSICA": "#c9b339", "SFX": "#c9b339", "VOZ": "#c9b339",
"IA": "#a78bfa", "STOCK": "#5aa8e0", "GUION": "#9aa3ad", "FOTOS": "#f5b84b",
"DOCUMENTOS": "#9aa3ad", "CELULAR": "#e0574f", "CONTENEDOR": "#6b7280", "REVISAR": "#ff6b6b",
"PROXY": "#555c6b", "BASURA": "#3a3f4a", "APARTADO": "#9aa3ad", "": "#4b5563",
}
# Todo lo de la app en la carpeta del cliente vive aquí: los iconos de color, y lo que venga.
CARPETA_APP = ".organilab"
# Qué es cada rol dicho en llano, para que el mapa pueda explicar qué cae en cada carpeta. # Qué es cada rol dicho en llano, para que el mapa pueda explicar qué cae en cada carpeta.
ROL_QUE = { ROL_QUE = {
"camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)", "camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)",

Ver fichero

@@ -160,6 +160,16 @@
.arbol .nd .cif{margin-left:auto;font-family:Manrope,system-ui,sans-serif;font-size:11px;color:var(--mut);white-space:nowrap} .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 .hijos{margin-left:9px;border-left:1px solid var(--line);padding-left:11px}
.arbol .vacio{color:var(--mut);font-weight:400} .arbol .vacio{color:var(--mut);font-weight:400}
.arbol .pt{width:9px;height:9px;border-radius:3px;flex:none;background:var(--line)}
.arbol .nd.hit{background:var(--ok2);outline:1px solid var(--ok)}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0}
.chips button{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;padding:3px 8px}
.resp{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:10px 12px;border-radius:8px;background:var(--panel2)}
.resp .pt{width:12px;height:12px;border-radius:4px;flex:none;margin-top:3px}
.resp .d{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all;font-weight:600}
.leyenda-col{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:10px;font-size:12px;color:var(--mut)}
.leyenda-col span{display:flex;align-items:center;gap:6px}
.leyenda-col i{width:10px;height:10px;border-radius:3px;display:inline-block}
.det h4{margin:0 0 4px;font-size:13.5px} .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 .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 ul{margin:4px 0 0;padding-left:18px;font-size:12.5px}
@@ -437,7 +447,17 @@
<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> <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"> <div class="toolbar">
<label>tipo de trabajo <select id="mapaTipo"></select></label> <label>tipo de trabajo <select id="mapaTipo"></select></label>
<span class="sep"></span><span id="mapaResumen" class="mut"></span> <span class="sep"></span>
<button id="btnPintar" title="Le pone a cada carpeta un icono del color de lo que guarda, para verlo en el Explorador">Pintar las carpetas en Windows</button>
<button class="lnk" id="btnDespintar" style="display:none">Quitar los colores</button>
<span id="mapaResumen" class="mut"></span>
</div>
<div class="box plano">
<h3>¿Dónde va a parar este archivo?</h3>
<p class="mut" style="font-size:12.5px;margin:0 0 8px">Escribe el nombre de cualquier archivo, exista o no, y te digo qué es y en qué carpeta lo dejaría. Lo decide el mismo clasificador que usa el escaneo.</p>
<input type="text" id="mapaArchivo" placeholder="A006_12171629_C001.braw" style="width:100%;max-width:460px">
<div class="chips" id="mapaEjemplos"></div>
<div id="mapaRespuesta"></div>
</div> </div>
<div class="row" style="grid-template-columns:1.35fr 1fr"> <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>Cómo van a quedar las carpetas</h3><div id="mapaArbol" class="arbol mut">cargando…</div></div>
@@ -1294,7 +1314,7 @@ $('#btnVaciasBorrar').onclick = () => {
// ---------------- mapa: cómo van a quedar las carpetas ---------------- // ---------------- mapa: cómo van a quedar las carpetas ----------------
const SEP = '\\'; const SEP = '\\';
let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {}; let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {}, MAPAE_DESTINO = '';
const cargarMapaEst = seguro(async (tipo) => { const cargarMapaEst = seguro(async (tipo) => {
const raiz = raizActual(); const raiz = raizActual();
const sel = $('#mapaTipo'); const sel = $('#mapaTipo');
@@ -1315,7 +1335,9 @@ function anotarMapa(n, base){
function nodoHtml(n){ function nodoHtml(n){
const cif = n.n ? `${num(n.n)} · ${gb(n.bytes)}` : ''; 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('. '); 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)}"` : ''}>` + const hit = MAPAE_DESTINO && (n.cubetas || []).includes(MAPAE_DESTINO);
return `<div><div class="nd ${MAPAE_SEL === n._id ? 'sel' : ''} ${hit ? 'hit' : ''} ${n.n ? '' : 'vacio'} ${n.variable ? 'var' : ''}" data-id="${n._id}" tabindex="0" role="button"${que ? ` title="${esc(que)}"` : ''}>` +
`<i class="pt"${n.color ? ` style="background:${n.color}"` : ''}></i>` +
`<span class="nm">${esc(n.nombre)}${SEP}</span>` + `<span class="nm">${esc(n.nombre)}${SEP}</span>` +
`${cif ? `<span class="cif">${cif}</span>` : ''}</div>` + `${cif ? `<span class="cif">${cif}</span>` : ''}</div>` +
`${n.hijos && n.hijos.length ? `<div class="hijos">${n.hijos.map(nodoHtml).join('')}</div>` : ''}</div>`; `${n.hijos && n.hijos.length ? `<div class="hijos">${n.hijos.map(nodoHtml).join('')}</div>` : ''}</div>`;
@@ -1324,8 +1346,15 @@ function pintarMapaEst(){
if (!MAPAE) return; if (!MAPAE) return;
$('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo; $('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo;
$('#mapaArbol').classList.remove('mut'); $('#mapaArbol').classList.remove('mut');
const cols = MAPAE.cubetas.filter(c => c.plantilla && c.n).sort((a, b) => b.bytes - a.bytes);
$('#mapaArbol').innerHTML = nodoHtml(MAPAE.arbol) + $('#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>'; '<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 o cambiarle el nombre.</div>' +
(cols.length ? `<div class="leyenda-col">${cols.map(c => `<span><i style="background:${c.color}"></i>${esc(NOMBRE_CUBETA[c.clave] || c.titulo)}</span>`).join('')}</div>` : '');
$('#btnDespintar').style.display = MAPAE.pintado ? '' : 'none';
$('#btnPintar').textContent = MAPAE.pintado ? 'Volver a pintar' : 'Pintar las carpetas en Windows';
$('#mapaEjemplos').innerHTML = MAPAE.cubetas.filter(c => c.ejemplos.length).slice(0, 8)
.map(c => `<button data-ej="${esc(c.ejemplos[0])}">${esc(c.ejemplos[0])}</button>`).join('');
$$('#mapaEjemplos button').forEach(b => b.onclick = () => { $('#mapaArchivo').value = b.dataset.ej; probarArchivo(); });
$$('#mapaArbol .nd').forEach(el => { $$('#mapaArbol .nd').forEach(el => {
const ir = () => { MAPAE_SEL = el.dataset.id; pintarMapaEst(); }; const ir = () => { MAPAE_SEL = el.dataset.id; pintarMapaEst(); };
el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } }; el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } };
@@ -1341,6 +1370,8 @@ function pintarMapaDetalle(){
caja.className = 'det'; caja.className = 'det';
const cubs = (n.cubetas || []).map(c => MAPAE.cubetas.find(x => x.clave === c)).filter(Boolean); 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>`; let h = `<h4>${esc(n.nombre)}</h4><div class="ruta">${esc((MAPAE.arbol.nombre || '') + SEP + n._r)}</div>`;
if (n.crudo && n.crudo.length && !n.variable)
h += `<div class="toolbar" style="margin:0 0 10px"><input type="text" id="mapaNombre" value="${esc(n.nombre)}" style="flex:1;min-width:150px" aria-label="Nombre de la carpeta"><button class="p" id="btnRenombrar">Cambiar el nombre</button></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.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.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){ if (n.apps_de){
@@ -1361,6 +1392,12 @@ function pintarMapaDetalle(){
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>`; 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; caja.innerHTML = h;
engancharPlantillas(caja); engancharPlantillas(caja);
const bn = $('#btnRenombrar');
if (bn) {
const pedir = () => renombrarCarpeta(n, $('#mapaNombre').value.trim());
bn.onclick = pedir;
$('#mapaNombre').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); pedir(); } };
}
} }
function pintarSinSitio(){ function pintarSinSitio(){
const sin = MAPAE.cubetas.filter(c => c.asignable && !c.plantilla); const sin = MAPAE.cubetas.filter(c => c.asignable && !c.plantilla);
@@ -1398,6 +1435,72 @@ async function guardarPlantilla(clave, valor){
// accion() pinta un spinner dentro del elemento: en un <select> eso se llevaría por delante sus opciones // accion() pinta un spinner dentro del elemento: en un <select> eso se llevaría por delante sus opciones
$('#mapaTipo').onchange = () => cargarMapaEst($('#mapaTipo').value); $('#mapaTipo').onchange = () => cargarMapaEst($('#mapaTipo').value);
// ---- renombrar una carpeta del mapa ----
const renombrarCarpeta = seguro(async (nodo, nombre) => {
if (!nombre || nombre === nodo.nombre) return;
const raiz = raizActual();
let existen = {n: 0, carpetas: []};
if (raiz) existen = await api(`/api/estructura/existen?raiz=${encodeURIComponent(raiz)}&ruta=${encodeURIComponent(JSON.stringify(nodo.crudo))}`);
const cuenta = existen.n;
modal('Cambiar el nombre de la carpeta', `<p>A partir de ahora, lo que iba a <span class="path">${esc(nodo.nombre)}</span> va a ir a <span class="path">${esc(nombre)}</span>.</p>` +
(cuenta ? `<label style="display:flex;gap:8px;align-items:flex-start;margin-top:10px;font-size:13px"><input type="checkbox" id="mDisco" checked style="margin-top:3px"><span>Cambiarle el nombre también a ${cuenta === 1 ? 'la carpeta que ya existe' : `las <b>${cuenta}</b> carpetas que ya existen`} con ese nombre. Es un movimiento normal: se deshace desde Historial.</span></label><div class="ejtipo" style="margin-top:8px">${existen.carpetas.slice(0, 6).map(c => `<div><b>${esc(c)}</b></div>`).join('')}${cuenta > 6 ? `<div><span>… y ${cuenta - 6} más</span></div>` : ''}</div>`
: '<p class="mut" style="font-size:12.5px">No hay ninguna carpeta con ese nombre en el disco todavía; solo cambia lo que la app hará de ahora en adelante.</p>'), () => accion($('#btnRenombrar') || $('#mapaTipo'), async () => {
const r = await api('/api/estructura/renombrar', {tipo: MAPAE.tipo, ruta: nodo.crudo, nombre,
raiz: raiz || null, en_disco: cuenta ? $('#mDisco').checked : false});
if (r.error) throw new ApiError(r.error);
PLAN = null; estadoPlan();
await cargarEstado();
await cargarMapaEst(MAPAE.tipo);
const vuelta = Object.values(MAPAE_NODOS).find(x => x.nombre === nombre);
if (vuelta) { MAPAE_SEL = vuelta._id; pintarMapaEst(); }
aviso(r.movidas ? `Ahora se llama «${nombre}»; ${r.movidas} carpeta${r.movidas > 1 ? 's' : ''} renombrada${r.movidas > 1 ? 's' : ''} en el disco.` : `Ahora se llama «${nombre}».`,
(r.errores || []).length ? 'bad' : 'ok', r.movidas ? {texto: 'Ver historial', ir: () => irA('historial')} : null);
}));
$('#modalSi').textContent = 'Cambiar el nombre';
});
// ---- probar dónde iría un archivo ----
let PROBAR_T = null;
const probarArchivo = seguro(async () => {
const nombre = $('#mapaArchivo').value.trim();
const caja = $('#mapaRespuesta');
if (!nombre) { caja.innerHTML = ''; MAPAE_DESTINO = ''; pintarMapaEst(); return; }
const d = await api(`/api/estructura/archivo?nombre=${encodeURIComponent(nombre)}&tipo=${encodeURIComponent(MAPAE ? MAPAE.tipo : 'contenido')}`);
if (d.error) { caja.innerHTML = `<p class="mut">${esc(d.error)}</p>`; return; }
const raizN = MAPAE && MAPAE.arbol ? MAPAE.arbol.nombre : '';
const fecha = d.por_fecha === 'mtime'
? 'el nombre no trae fecha, así que el mes lo saca de la fecha del archivo'
: `el nombre dice que es del ${d.fecha}`;
caja.innerHTML = `<div class="resp"><i class="pt" style="background:${d.color}"></i><div>` +
`<div><b>${esc(d.nombre)}</b> — ${esc(d.que)}${d.camara ? ` · cámara ${esc(d.camara)}` : ''}${d.app ? ` · proyecto de ${esc(d.app)}` : ''}</div>` +
`<div class="d" style="margin:4px 0">${esc(raizN + SEP + d.destino)}${SEP}</div>` +
`<div class="mut" style="font-size:12px">${esc(fecha)}. Si el archivo está dentro de una carpeta que la app reconoce, esa carpeta puede mandar sobre esto.</div>` +
'</div></div>';
MAPAE_DESTINO = d.cubeta;
pintarMapaEst();
const hit = $('#mapaArbol .nd.hit');
if (hit) hit.scrollIntoView({block: 'center', behavior: 'smooth'});
});
$('#mapaArchivo').oninput = () => { clearTimeout(PROBAR_T); PROBAR_T = setTimeout(probarArchivo, 250); };
// ---- colores en el Explorador ----
$('#btnPintar').onclick = () => {
const raiz = raizActual(); if (!raiz) return aviso('Conecta una carpeta primero.', 'bad');
modal('Pintar las carpetas en Windows', '<p>A cada carpeta se le pone un icono del color de lo que guarda, para verlo en el Explorador sin abrir la app.</p><p class="mut" style="font-size:12.5px">Windows no tiene «color de carpeta», así que se hace por el mismo camino que «Propiedades › Personalizar › Cambiar icono»: un <span class="path">desktop.ini</span> y los iconos en una carpeta oculta dentro del cliente. No se mueve ni se borra ningún archivo, y se quita con un clic. En algunos discos de red Windows no lo permite; si pasa, te lo digo y no dejo nada escrito.</p>',
() => accion($('#btnPintar'), async () => {
const r = await api('/api/colores/pintar', {raiz});
if (r.error) throw new ApiError(r.error);
aviso(`${r.pintadas} carpetas pintadas con ${r.colores} colores.` + (r.sin_marca ? ` ${r.sin_marca} no se pudieron: el disco no lo permite.` : '') + ' Si el Explorador ya estaba abierto, pulsa F5.', 'ok');
await cargarMapaEst(MAPAE.tipo);
}));
$('#modalSi').textContent = 'Pintar';
};
$('#btnDespintar').onclick = () => accion($('#btnDespintar'), async () => {
const r = await api('/api/colores/quitar', {raiz: raizActual()});
aviso(`${r.quitadas} carpetas vueltas a su icono normal.`, 'ok');
await cargarMapaEst(MAPAE.tipo);
});
// ---------------- 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'); }));