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

@@ -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":