2.6.0: auditoría, 100 % local y 26 funciones verificadas
Sale a la red solo lo que se pide: la tipografía viene con el programa (static/manrope.woff2) y el comprobador de versiones tiene interruptor en Ajustes. Nueva prueba funcional (prueba_funcional.py) que levanta una copia sobre un cliente de ensayo y ejercita las 26 funciones por su API, con una conversión real: 26 de 26. Fallos que sacó y quedan arreglados: - Un renombrado solo de mayúsculas («10- octubre» → «10- Octubre») se descartaba en Plan.agregar y en mover; deshacer decía «sitio ocupado». - Las carpetas que crea el plan no entraban en el inventario hasta el siguiente escaneo (_recontar_carpetas las da de alta y recuenta). - Una fusión de carpetas cortaba en el primer archivo que tropezaba. - «Viejo» era distinto en convertidor, stock y respaldo: ahora es_viejo. - /api/actualizacion informaba versión vacía hasta la primera consulta. - Filas de sueltos por carpeta de origen; mes del stock el más repetido; fuera tres rutas sin uso. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
115
organizador.py
115
organizador.py
@@ -37,7 +37,7 @@ import actualizador
|
||||
asistente.O = sys.modules[__name__]
|
||||
actualizador.O = sys.modules[__name__]
|
||||
|
||||
VERSION = "2.5.0"
|
||||
VERSION = "2.6.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
|
||||
@@ -713,7 +713,12 @@ class Plan:
|
||||
marcado=None, kw_destinos=None):
|
||||
destino = os.path.normpath(destino)
|
||||
if archivos is None and os.path.normpath(origen).lower() == destino.lower():
|
||||
return
|
||||
# el mismo sitio con otras mayúsculas solo tiene sentido como renombrado («10- octubre» ->
|
||||
# «10- Octubre»); antes se descartaba también ese, y el plan prometía meses «con nombre
|
||||
# completo» que nunca se normalizaban
|
||||
if accion != "renombrar" or os.path.normpath(origen) == destino:
|
||||
return
|
||||
solo_mayusculas = os.path.normpath(origen).lower() == destino.lower()
|
||||
if n is None or bytes_ is None:
|
||||
if archivos is not None:
|
||||
n = len(archivos)
|
||||
@@ -737,7 +742,7 @@ class Plan:
|
||||
elif archivos is None and accion != "renombrar" and os.path.isfile(destino):
|
||||
aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá"
|
||||
confianza, marcado = "baja", False
|
||||
elif archivos is None and os.path.isdir(destino) and os.path.isdir(origen):
|
||||
elif archivos is None and not solo_mayusculas and os.path.isdir(destino) and os.path.isdir(origen):
|
||||
aviso_colision = " · la carpeta destino ya existe: se fusionan"
|
||||
self.filas.append({
|
||||
"id": len(self.filas) + 1,
|
||||
@@ -761,11 +766,11 @@ class Plan:
|
||||
})
|
||||
|
||||
def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
|
||||
clave = (cubeta, destino_rel, motivo, confianza)
|
||||
clave = (cubeta, destino_rel, motivo, confianza, os.path.normcase(os.path.dirname(fila["ruta"])))
|
||||
self.sueltos[clave].append((fila["ruta"], fila["bytes"]))
|
||||
|
||||
def volcar_sueltos(self):
|
||||
for (cubeta, destino_rel, motivo, confianza), archivos in self.sueltos.items():
|
||||
for (cubeta, destino_rel, motivo, confianza, _dir), archivos in self.sueltos.items():
|
||||
origen = os.path.dirname(archivos[0][0])
|
||||
self.agregar(origen, self.abs_(destino_rel), cubeta, "mover archivos", motivo, confianza, archivos=archivos)
|
||||
self.sueltos.clear()
|
||||
@@ -1238,16 +1243,35 @@ def mover(origen, destino, lote, movidos):
|
||||
"""mueve archivo o carpeta; si la carpeta destino existe, fusiona"""
|
||||
origen = os.path.normpath(origen)
|
||||
destino = os.path.normpath(destino)
|
||||
if origen == destino:
|
||||
return
|
||||
if origen.lower() == destino.lower():
|
||||
# solo cambian mayúsculas («10- octubre» -> «10- Octubre»). En Windows es el mismo nombre, así
|
||||
# que isdir/exists del destino dicen que sí y el camino normal lo tomaría por una fusión de una
|
||||
# carpeta consigo misma. Antes se devolvía sin hacer nada y el plan prometía un renombrado que
|
||||
# nunca ocurría: os.rename sí sabe hacerlo en NTFS.
|
||||
os.rename(origen, destino)
|
||||
bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
|
||||
movidos.append((origen, destino))
|
||||
return
|
||||
if os.path.isdir(origen) and os.path.isdir(destino):
|
||||
# fusión: cada hijo va por su cuenta. Si uno tropieza (ya existe con ese nombre, está abierto),
|
||||
# los demás siguen; antes el primero que fallaba cortaba el bucle y la carpeta quedaba medio
|
||||
# vaciada, con el aviso hablando de un solo archivo.
|
||||
fallos = []
|
||||
for nombre in os.listdir(origen):
|
||||
mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos)
|
||||
try:
|
||||
mover(os.path.join(origen, nombre), os.path.join(destino, nombre), lote, movidos)
|
||||
except OSError as ex:
|
||||
fallos.append(f"{nombre}: {ex}")
|
||||
try:
|
||||
os.rmdir(origen)
|
||||
bitacora({"lote": lote, "op": "rmdir", "de": origen})
|
||||
except OSError:
|
||||
pass
|
||||
if fallos:
|
||||
raise OSError(f"{len(fallos)} de «{os.path.basename(origen)}» se quedaron: " + "; ".join(fallos[:3])
|
||||
+ (f"; y {len(fallos) - 3} más" if len(fallos) > 3 else ""))
|
||||
return
|
||||
os.makedirs(os.path.dirname(destino), exist_ok=True)
|
||||
if os.path.exists(destino):
|
||||
@@ -1371,9 +1395,57 @@ def actualizar_inventario(movidos):
|
||||
(fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"],
|
||||
fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"],
|
||||
fila["es_proxy"], fila["es_basura"]))
|
||||
_recontar_carpetas(con, movidos)
|
||||
con.close()
|
||||
|
||||
|
||||
def _recontar_carpetas(con, movidos):
|
||||
"""Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no
|
||||
existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los
|
||||
colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del
|
||||
escaneo."""
|
||||
tocadas = set()
|
||||
for de, a in movidos:
|
||||
for ruta in (os.path.dirname(de), os.path.dirname(a), a if os.path.isdir(a) else None):
|
||||
if not ruta:
|
||||
continue
|
||||
raiz = _raiz_de(ruta)
|
||||
if not raiz:
|
||||
continue
|
||||
d = os.path.normpath(ruta)
|
||||
while d.lower().startswith(raiz.lower()) and d.lower() != raiz.lower():
|
||||
tocadas.add((d, raiz))
|
||||
padre = os.path.dirname(d)
|
||||
if padre == d:
|
||||
break
|
||||
d = padre
|
||||
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
|
||||
for d, raiz in tocadas:
|
||||
if not os.path.isdir(d):
|
||||
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
|
||||
continue
|
||||
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'",
|
||||
(d, esc_like(d + os.sep) + "%")).fetchone()
|
||||
previa = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (d,)).fetchone()
|
||||
en_papelera = 1 if any(m in d + os.sep for m in marcas) else 0
|
||||
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(d, raiz, os.path.basename(d), fila["n"], fila["b"],
|
||||
previa["anio_moda"] if previa else None, previa["mes_moda"] if previa else None,
|
||||
previa["frac_moda"] if previa else 0, previa["dias_rango"] if previa else 0,
|
||||
previa["es_proxy"] if previa else (1 if R.es_proxy_dir(os.path.basename(d)) else 0),
|
||||
1 if en_papelera else (previa["es_basura"] if previa else 0)))
|
||||
|
||||
|
||||
def _raiz_de(ruta):
|
||||
"""La carpeta de cliente registrada a la que pertenece una ruta, o None."""
|
||||
r = os.path.normpath(ruta).lower()
|
||||
for c in CONFIG.get("raices", []):
|
||||
k = os.path.normpath(c["ruta"]).lower()
|
||||
if r == k or r.startswith(k + os.sep):
|
||||
return os.path.normpath(c["ruta"])
|
||||
return None
|
||||
|
||||
|
||||
def quitar_vacias(ruta, tope):
|
||||
"""borra carpetas vacías que quedaron entre `ruta` y `tope` (solo si están vacías)"""
|
||||
ruta = os.path.normpath(ruta)
|
||||
@@ -1497,6 +1569,10 @@ def deshacer(lote):
|
||||
else:
|
||||
if not os.path.exists(e["a"]):
|
||||
errores.append(f"{os.path.basename(e['a'])}: ya no está donde la app lo dejó; no se devuelve")
|
||||
elif e["a"].lower() == e["de"].lower():
|
||||
os.rename(e["a"], e["de"]) # solo cambiaban las mayúsculas: exists() diría que está ocupado
|
||||
movidos.append((e["a"], e["de"]))
|
||||
deshechos += 1
|
||||
elif os.path.exists(e["de"]):
|
||||
errores.append(f"{os.path.basename(e['de'])}: su sitio original ya está ocupado; no se devuelve")
|
||||
else:
|
||||
@@ -1918,7 +1994,7 @@ def estimar_bytes(f, perfil, formato):
|
||||
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
con = db()
|
||||
limite = time.time() - int(meses) * 30.4 * 86400
|
||||
tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad
|
||||
# es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
|
||||
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 "
|
||||
"AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||||
@@ -1940,7 +2016,7 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
|
||||
motivo_no = "ya está en HEVC/AV1 eficiente"
|
||||
elif motor == "resolve" and formato != "h265_10bit":
|
||||
motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits"
|
||||
elif f["mtime"] > limite:
|
||||
elif tope and not es_viejo(f, tope):
|
||||
motivo_no = f"tiene menos de {meses} meses"
|
||||
elif f["contexto"] == "EXPORTADOS" and not incluir_exportados:
|
||||
motivo_no = "es un exportado / master"
|
||||
@@ -2432,12 +2508,15 @@ def stock_viejo(raiz, meses, tipos):
|
||||
if not es_viejo(f, tope):
|
||||
continue
|
||||
rel = os.path.relpath(f["dir"], raiz)
|
||||
g = grupos.setdefault(rel, {"carpeta": rel, "n": 0, "bytes": 0, "rutas": [], "ejemplos": [], "mes": f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]})
|
||||
g = grupos.setdefault(rel, {"carpeta": rel, "n": 0, "bytes": 0, "rutas": [], "ejemplos": [], "meses": Counter()})
|
||||
g["meses"][f"{f['anio_ruta']}-{f['mes_ruta']:02d}" if f["anio_ruta"] and f["mes_ruta"] else f["fecha"][:7]] += 1
|
||||
g["n"] += 1
|
||||
g["bytes"] += f["bytes"]
|
||||
g["rutas"].append(f["ruta"])
|
||||
if len(g["ejemplos"]) < 3:
|
||||
g["ejemplos"].append(f["nombre"])
|
||||
for g in grupos.values():
|
||||
g["mes"] = g.pop("meses").most_common(1)[0][0]
|
||||
out = sorted(grupos.values(), key=lambda g: -g["bytes"])
|
||||
return {"grupos": out, "n": sum(g["n"] for g in out), "bytes": sum(g["bytes"] for g in out), "tope": f"{tope[0]}-{tope[1]:02d}"}
|
||||
|
||||
@@ -3702,7 +3781,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
p = os.path.join(BASE, "static", ruta[len("/static/"):])
|
||||
if not os.path.isfile(p):
|
||||
return self._json({"error": "no existe"}, 404)
|
||||
tipos = {".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css", ".js": "text/javascript"}
|
||||
tipos = {".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".css": "text/css",
|
||||
".js": "text/javascript", ".woff2": "font/woff2"}
|
||||
with open(p, "rb") as f:
|
||||
cuerpo = f.read()
|
||||
self.send_response(200)
|
||||
@@ -3724,11 +3804,7 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
if ruta == "/api/mapa":
|
||||
return self._json(mapa(q["raiz"]))
|
||||
if ruta == "/api/actualizacion":
|
||||
return self._json({"version": VERSION, **actualizador.ESTADO})
|
||||
if ruta == "/api/diagnostico":
|
||||
return self._json(asistente.diagnostico(q["raiz"]))
|
||||
if ruta == "/api/asistente/estado":
|
||||
return self._json(asistente.ESTADO)
|
||||
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
|
||||
if ruta == "/api/asistente/pasos":
|
||||
raiz = os.path.normpath(q["raiz"])
|
||||
r = raiz_cfg(raiz)
|
||||
@@ -3861,7 +3937,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"ok": True})
|
||||
if ruta == "/api/config":
|
||||
# solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json
|
||||
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo"}, "respaldo": {"destino"}}
|
||||
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo"},
|
||||
"respaldo": {"destino"}, "actualizaciones": {"auto"}}
|
||||
for k, v in d.items():
|
||||
if k in permitidas and isinstance(v, dict):
|
||||
CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]})
|
||||
@@ -3916,14 +3993,10 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json(deshacer(d["lote"]))
|
||||
if ruta == "/api/actualizacion/buscar":
|
||||
actualizador.comprobar()
|
||||
return self._json({"version": VERSION, **actualizador.ESTADO})
|
||||
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
|
||||
if ruta == "/api/actualizacion/instalar":
|
||||
ok = actualizador.descargar_y_aplicar(en_hilo=True)
|
||||
return self._json({"lanzado": bool(ok), **actualizador.ESTADO})
|
||||
if ruta == "/api/tour-visto":
|
||||
CONFIG["tour_visto"] = True
|
||||
guardar_json(CONFIG_PATH, CONFIG)
|
||||
return self._json({"ok": True})
|
||||
if ruta == "/api/acciones/hacer":
|
||||
# «Hacer todo lo seguro»: el mismo motor que tenía el asistente, pero lanzado desde la
|
||||
# lista de acciones y sin preguntar nada. Deja fuera lo que no debe ir en automático:
|
||||
|
||||
Referencia en una nueva incidencia
Block a user