diff --git a/README.md b/README.md index 9ebd58c..9d64f69 100644 --- a/README.md +++ b/README.md @@ -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 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 exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: diff --git a/manifiesto.json b/manifiesto.json index 43e3527..bb1c3d4 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "2.3.0", - "builtAt": "2026-09-17T03:31:56.000Z", - "archivo": "OrganizadorLabstream-portatil-2.3.0.zip", - "size": 198716642, - "sha256": "1fe85cae6053dca7b0b381312fcac570d4595a78b7fd55254662f61d2be8521b", + "version": "2.4.0", + "builtAt": "2026-09-17T03:49:29.000Z", + "archivo": "OrganizadorLabstream-portatil-2.4.0.zip", + "size": 198734267, + "sha256": "02a9dade711cab28953b8e110d3440e4c2262287354e20b0d99ea00e05198b8f", "app": { - "archivo": "OrganizadorLabstream-app-2.3.0.zip", - "size": 14896889, - "sha256": "731ea6a9477d0bf2c04cc26a8a92fe6615e5e45ed1313d43020de16c7599ca70", - "url": "/api/herramientas/OrganizadorLabstream-app-2.3.0.zip" + "archivo": "OrganizadorLabstream-app-2.4.0.zip", + "size": 14914514, + "sha256": "fc5e250afb755748b3c8a227780fef44c0b231026087247328e7d723cb87135a", + "url": "/api/herramientas/OrganizadorLabstream-app-2.4.0.zip" }, "notes": "", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "bd0526e1cb3db8a7a6f8ecd0ddac54d627dee221713adba628eb6bbde1e601952770aebcc8ca07c379047af960117c0403cb12b19d80bf8a61a607d330365c3facd34d49b6c615e7cd6c1bd92eb9878f0961fa7f85953d235368abdf8704a12066ed6b31f2588201ef175fef264db0c14b318f50b587afbe73246405cbe423fe403376e00ff7610f89948806169ea3e13cd4aaf9b30565bba184083b11e9e49c01157b340d2e019bc23aed6aaa7b992e5ec7a2408c476352c603b6e5c5601a282d9e0eb6e724036c01b12f06011e23d538dae83ded019475800cfe62876b3a6d42b23f4fbadd1371b5b039328a44e33a8cadb94e630c39a30883b33a78b2d3ab" + "valor": "41707cfc81c53d0f2aa3c2cd2180c49fa588beef835d159f4caf60ef9a3e32aefa56a02f29143867eba7902f1ddf71c9a92332911ee617367be5634daff2eb293bf9a11ab668e827e0012159785b7a1d0f37ac8d76ad588f9d1521bf89c0d2539e6a6812d47fb7beae027493f6b53f844eaa655c81415396413031bd8aff59a49a5592e9263af442a7c853e17a63cb6b274e12f88d2066295ed0f6b3a030a938921e513e539b6fbe968348de5618b6421b48d5b72758c8adef2cdaeec768bf75aa45fecd390392e693038a600dd051f62aa47a62910f5ded9601ba0a3f402feb0f9389b3a27786a1b6e7ca5f7f57c21f93e80d09ecd675b9c3200241ccb7aea4" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 4ea666a..982aaf4 100644 --- a/organizador.py +++ b/organizador.py @@ -14,6 +14,9 @@ import os import sys import re import json +import zlib +import struct +import ctypes import time import queue import shutil @@ -34,7 +37,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "2.3.0" +VERSION = "2.4.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 @@ -437,8 +440,8 @@ def escanear(ruta): try: nombre = e.name if e.is_dir(follow_symlinks=False): - if nombre.lower() in ("#recycle", "@eadir"): - continue + if nombre.lower() in ("#recycle", "@eadir", R.CARPETA_APP): + continue # lo de la app (sus iconos de color) no es material del cliente 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 # una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro: @@ -461,6 +464,8 @@ def escanear(ruta): continue walk(e.path, p, b, s, c2, n2, a2, m2, vistos) 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) ext = os.path.splitext(nombre)[1].lower() firma = R.firma_archivo(nombre) @@ -2898,19 +2903,32 @@ def mapa_estructura(tipo, raiz=None): apps[a][1] += f["b"] 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 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} + "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"]: if h["nombre"].lower() == nombre.lower(): 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) return h @@ -2920,16 +2938,19 @@ def mapa_estructura(tipo, raiz=None): n, b = hay.get(cub, [0, 0]) cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla, "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, ""), "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 + nodo, crudo = raiz_nodo, [] for seg in plantilla.split("/"): 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 " "carpeta, se queda con el nombre de esa carpeta") 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: texto = texto.replace("{" + k + "}", ej) 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["n"] += n nodo["bytes"] += b @@ -2951,14 +2973,424 @@ def mapa_estructura(tipo, raiz=None): 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())) + cols = {R.COLOR_CUBETA.get(c, "") for c in nodo["cubetas"]} for h in nodo["hijos"]: ordenar(h) nodo["n"] += h["n"] 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) 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(" 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. ROL_QUE = { "camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)", diff --git a/static/index.html b/static/index.html index 85d3ccd..36ac31f 100644 --- a/static/index.html +++ b/static/index.html @@ -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 .hijos{margin-left:9px;border-left:1px solid var(--line);padding-left:11px} .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 .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} @@ -437,7 +447,17 @@

Este árbol sale de las plantillas de verdad, así que es exactamente lo que la app va a hacer al ordenar. ?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.

- + + + + +
+
+

¿Dónde va a parar este archivo?

+

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.

+ +
+

Cómo van a quedar las carpetas

cargando…
@@ -1294,7 +1314,7 @@ $('#btnVaciasBorrar').onclick = () => { // ---------------- mapa: cómo van a quedar las carpetas ---------------- const SEP = '\\'; -let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {}; +let MAPAE = null, MAPAE_SEL = '', MAPAE_NODOS = {}, MAPAE_DESTINO = ''; const cargarMapaEst = seguro(async (tipo) => { const raiz = raizActual(); const sel = $('#mapaTipo'); @@ -1315,7 +1335,9 @@ function anotarMapa(n, base){ 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 `
` + + const hit = MAPAE_DESTINO && (n.cubetas || []).includes(MAPAE_DESTINO); + return `
` + + `` + `${esc(n.nombre)}${SEP}` + `${cif ? `${cif}` : ''}
` + `${n.hijos && n.hijos.length ? `
${n.hijos.map(nodoHtml).join('')}
` : ''}
`; @@ -1324,8 +1346,15 @@ function pintarMapaEst(){ if (!MAPAE) return; $('#mapaSub').textContent = (MAPAE.cliente ? MAPAE.cliente + ' · ' : '') + MAPAE.titulo; $('#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) + - '
Las carpetas en cursiva cambian con el material: el año, el mes, el nombre de la pieza o el programa. Toca cualquiera para ver qué guarda.
'; + '
Las carpetas en cursiva 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.
' + + (cols.length ? `
${cols.map(c => `${esc(NOMBRE_CUBETA[c.clave] || c.titulo)}`).join('')}
` : ''); + $('#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 => ``).join(''); + $$('#mapaEjemplos button').forEach(b => b.onclick = () => { $('#mapaArchivo').value = b.dataset.ej; probarArchivo(); }); $$('#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(); } }; @@ -1341,6 +1370,8 @@ function pintarMapaDetalle(){ caja.className = 'det'; const cubs = (n.cubetas || []).map(c => MAPAE.cubetas.find(x => x.clave === c)).filter(Boolean); let h = `

${esc(n.nombre)}

${esc((MAPAE.arbol.nombre || '') + SEP + n._r)}
`; + if (n.crudo && n.crudo.length && !n.variable) + h += `
`; if (n.variable) h += `

El nombre cambia con el material: es ${esc(n.variable)}.

`; if (n.nota) h += `

${esc(n.nota.charAt(0).toUpperCase() + n.nota.slice(1))}.

`; if (n.apps_de){ @@ -1361,6 +1392,12 @@ function pintarMapaDetalle(){ if (cubs.length) h += `
Puedes usar: ${Object.entries(MAPAE.variables).map(([k, v]) => `
{${k}} — ${esc(v)}
`).join('')}
`; caja.innerHTML = h; 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(){ 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 Cambiarle el nombre también a ${cuenta === 1 ? 'la carpeta que ya existe' : `las ${cuenta} carpetas que ya existen`} con ese nombre. Es un movimiento normal: se deshace desde Historial.
${existen.carpetas.slice(0, 6).map(c => `
${esc(c)}
`).join('')}${cuenta > 6 ? `
… y ${cuenta - 6} más
` : ''}
` + : '

No hay ninguna carpeta con ese nombre en el disco todavía; solo cambia lo que la app hará de ahora en adelante.

'), () => 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 = `

${esc(d.error)}

`; 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 = `
` + + `
${esc(d.nombre)} — ${esc(d.que)}${d.camara ? ` · cámara ${esc(d.camara)}` : ''}${d.app ? ` · proyecto de ${esc(d.app)}` : ''}
` + + `
${esc(raizN + SEP + d.destino)}${SEP}
` + + `
${esc(fecha)}. Si el archivo está dentro de una carpeta que la app reconoce, esa carpeta puede mandar sobre esto.
` + + '
'; + 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', '

A cada carpeta se le pone un icono del color de lo que guarda, para verlo en el Explorador sin abrir la app.

Windows no tiene «color de carpeta», así que se hace por el mismo camino que «Propiedades › Personalizar › Cambiar icono»: un desktop.ini 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.

', + () => 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 ---------------- const zona = $('#zonaSoltar'); ['dragenter','dragover'].forEach(ev => document.addEventListener(ev, e => { e.preventDefault(); zona.classList.add('sobre'); $('#vacioCaja').classList.add('sobre'); }));