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:
469
organizador.py
469
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("<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):
|
||||
@@ -3308,6 +3740,16 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
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/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":
|
||||
raiz = q.get("raiz")
|
||||
tipo = q.get("tipo")
|
||||
@@ -3500,6 +3942,13 @@ 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/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":
|
||||
return self._json(borrar_vacias(os.path.normpath(d["raiz"]), d.get("rutas", [])))
|
||||
if ruta == "/api/duplicados/apartar":
|
||||
|
||||
Referencia en una nueva incidencia
Block a user