1.8.0: red de seguridad tras la auditoría

- Deshacer vuelve a la interfaz (Historial, Últimos cambios y el aviso al aplicar).
- «Elements» ya no marca como stock lo que tenga dentro: 50,7 GB de material
  propio dejaban de respaldarse y salían como desechables.
- El plan contrasta el mes de la carpeta con la fecha de los archivos y avisa
  cuando no cuadran (727 GB en el material real iban al mes equivocado en verde).
- aplicar(): un archivo bloqueado ya no corta el resto de su grupo.
- Un escaneo interrumpido no reemplaza el inventario bueno.
- Las uniones de carpeta de Windows no se recorren (colgaban el escaneo).
- Abrir una carpeta pasó de 8,83 s a 0,21 s.
- Plantillas: _Papelera migra a _Para borrar.
- .dat/.idx/.bak dejan de ser basura; Cache deja de ser proxies; abreviaturas
  de mes bajan a confianza media.
- Convertidor: varios formatos por clip, originales apartados fuera de
  candidatos, sufijo en modo espejo, temporal borrado al fallar, una sola cola.
- Interfaz: accion() no pisa el estado del botón; rutas de Windows bien recortadas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 10:43:42 -05:00
padre 6bdf6a8457
commit b977fe1b3e
Se han modificado 5 ficheros con 315 adiciones y 80 borrados

Ver fichero

@@ -302,6 +302,36 @@ diálogo en un PowerShell aparte para no bloquear el servidor y devuelve la ruta
- Barra de tareas revisada: la conversión en curso llegaba como un id y la interfaz esperaba un objeto (salía
0 % sin nombre); ahora `estado.cola` trae `actual` (ruta, pct, raíz), `hechos` y `total`.
1.8.0, auditoría y red de seguridad. Tras revisar el código con cuatro pasadas y medir sobre el material real
(7,73 TB, 53.964 archivos):
- **Deshacer vuelve a la interfaz.** Al rediseñar el Historial en la 1.6.0 se quitaron los botones pero la app
seguía prometiendo en cuatro pantallas que «se deshace desde Historial». Ahora está en el Historial, en
«Últimos cambios» y en el aviso que sale al aplicar («Deshacer esto»).
- **«Elements» ya no convierte en stock lo que tenga dentro.** La marca solo la hereda una carpeta con nombre
completo de paquete de Envato; lo descargado se reconoce por el nombre del archivo. Medido: marcaba 1.713
archivos y 50,7 GB de material propio como desechable, que el respaldo excluye por defecto.
- **El plan contrasta el mes de la carpeta con la fecha de los archivos** (`_discrepa_mes`, `Plan.mes_real`).
Si no cuadran, baja a «probable», no se marca y sale un aviso arriba con los dos meses. 5 de las 10 carpetas
de mes con material fechable no contenían el mes que decían: son 727 GB. Los nombres Blackmagic no traen
año, así que en esos solo se compara el mes.
- **Un archivo bloqueado ya no corta el resto de su grupo** en `aplicar()`: cada archivo va por su cuenta y el
resumen dice cuántos fallaron de verdad (antes uno abierto en Resolve dejaba sin mover los 117 restantes).
- **Un escaneo interrumpido no reemplaza el inventario:** si hubo fallos de lectura y el recuento cae más de
un 10 % frente al anterior, se conserva el que había y se explica por qué.
- **Las uniones de carpeta de Windows no se recorren** (`_es_enlace`, `_id_carpeta`). Una circular dejaba el
escaneo dando vueltas sin fin: comprobado, un árbol de un archivo no terminaba en dos minutos.
- **Abrir una carpeta pasó de 8,83 s a 0,21 s:** `_resumen_carpetas` calcula de una sola pasada lo que antes
era una consulta por carpeta que recorría la tabla entera.
- **La papelera se llama igual en el disco que en pantalla.** Las plantillas guardadas con el nombre viejo
(`_Papelera`) se migran a `_Para borrar` al arrancar.
- Falsos positivos quitados: `.dat`, `.idx` y `.bak` ya no son basura de sistema, y una carpeta llamada
`Cache` a secas ya no son proxies. Las abreviaturas de mes («Mar», «May») bajan a confianza media.
- Convertidor: un clip ya convertido a un formato puede convertirse a otro (el filtro «en cola» miraba todos
los trabajos); los originales ya apartados no vuelven a salir como candidatos; en modo espejo cada formato
tiene su propio archivo; el temporal se borra si falla; y la cola no puede arrancar dos veces.
- Interfaz: los botones ya no vuelven a su etiqueta vieja pisando lo que la acción acaba de calcular, y las
rutas de Windows se recortan bien (soltar un archivo registraba el archivo como carpeta de cliente).
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:

Ver fichero

@@ -1,19 +1,19 @@
{
"nombre": "OrganiLAB",
"version": "1.7.0",
"builtAt": "2026-09-16T01:45:56.000Z",
"archivo": "OrganizadorLabstream-portatil-1.7.0.zip",
"size": 198673691,
"sha256": "2dad6903c9484815406c4d0f3e2e3ebf65a916990fc93ee3ade07ac489bd7f9e",
"version": "1.8.0",
"builtAt": "2026-09-16T15:38:23.000Z",
"archivo": "OrganizadorLabstream-portatil-1.8.0.zip",
"size": 198682459,
"sha256": "7975c51317de5c63aa8271a11434d183c783893a234718d24151264638fee751",
"app": {
"archivo": "OrganizadorLabstream-app-1.7.0.zip",
"size": 14853941,
"sha256": "faf85782a465f0d1cec062f6d7993e70e4e292adf2095f9864cbfafe4f33c935",
"url": "/api/herramientas/OrganizadorLabstream-app-1.7.0.zip"
"archivo": "OrganizadorLabstream-app-1.8.0.zip",
"size": 14862710,
"sha256": "e8ab00045256795e182563241c7f62a9582776d6f7effc866d3eab2013b7eb8b",
"url": "/api/herramientas/OrganizadorLabstream-app-1.8.0.zip"
},
"notes": "",
"firma": {
"alg": "rsa2048-pkcs1v15-sha256",
"valor": "bba9a7041615d6b3fc47ecdfee39e6835ed09d8f06bba6629b46e23d8e91a57c84ebf82ad0c9cda49632143a8f0b9baf56698393363c197604687a01f477cda7877cc7f4f6368460a85c08aaa5a17c3e2205ee5f3db598b02592b372f7ea479ca722a1498d2c0c13d6f95c02c1e8a0982ac280bf00b1cbcd71ee2d840a8a8a08038fc45fe7195cbf6ad1f2ab562d619c67060af21028bfab9f8213d9f77a6e974646e7c4c597356b094bfdc379950d2015a75ea131beaf34b29aab710934863e44b7e52055ee2b6d2108c53b2fe1c0249746f00537085a1d2ba051f653091d3b7fa9d523a3ba3d459f4c48ea3985a798d29214a985575e263cdebc4fac4cd493"
"valor": "41a83ab9e9918fe7d16ca9559a7524e1c88685223196c3c85b2f32ea0f71da819d8ae3b099c68fbbabc3e276782a92dcf8d9ea42facaf3ae594f3062297cec7770e2dc885f15ba083dc28e7fd5422ed4abd11ead1ebae7ef7c69a34018c88d8ac5e4bcb290c19ee789261001663234b7ae9d3372a7951947dbfe367cebb3a323f0688a8a177f07cc8827e8d293b8ac014a4f4348cabead577aa02d41d64a6f96af50c2cc70de6b809a3a4bda2dc13d6ee07c64924e19d02d5100600cdc9be5a273a121757a111c5ff20229f090306a39494e280679913d347843ba5193ab36d4f12b992f352e578815e1e535fc542f032ade94d16922bab8fc8f63c85bc760ba"
}
}

Ver fichero

@@ -17,6 +17,7 @@ import json
import time
import queue
import shutil
import stat as stat_mod
import hashlib
import sqlite3
import threading
@@ -33,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "1.7.0"
VERSION = "1.8.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
@@ -238,6 +239,11 @@ for t, d in R.PLANTILLAS.items():
PLANTILLAS.setdefault(t, d)
for b, p in d.items():
PLANTILLAS[t].setdefault(b, p)
# la papelera se llamaba «_Papelera» y pasó a «_Para borrar», pero las plantillas guardadas ganan
# sobre las de fábrica, así que las instalaciones viejas seguían escribiendo en la carpeta vieja
# mientras toda la interfaz hablaba de la nueva. Se migra una sola vez.
if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"):
PLANTILLAS[t][b] = p
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
@@ -354,10 +360,28 @@ def contexto_dir(nombre, ctx_padre, nivel_padre):
return cub, nivel_padre
def _es_enlace(entrada):
"""True si la entrada es un enlace o una unión de Windows (mklink /D o /J). `is_symlink` no basta:
para una unión devuelve False, y aun así al entrar se recorre otro árbol."""
try:
if entrada.is_symlink():
return True
return bool(entrada.stat(follow_symlinks=False).st_file_attributes & stat_mod.FILE_ATTRIBUTE_REPARSE_POINT)
except (OSError, AttributeError):
return False
def _id_carpeta(ruta):
"""Identificador real de una carpeta en el disco, para no recorrer dos veces la misma."""
st = os.stat(ruta)
return (st.st_dev, st.st_ino) if st.st_ino else os.path.normcase(os.path.abspath(ruta))
def escanear(ruta):
raiz = os.path.normpath(ruta)
prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""}
filas = []
fallos = [] # carpetas o archivos que no se pudieron leer: deciden si el escaneo vale
dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre]
props = ("n", "bytes")
@@ -369,10 +393,24 @@ def escanear(ruta):
info[1] += size
info[2][fecha_am] += 1
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes):
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes, vistos=None):
# `vistos` lleva los identificadores reales de carpeta ya recorridos: una unión de Windows
# (mklink /J) parece una carpeta normal, y una que apunte hacia arriba dejaba el escaneo dando
# vueltas sin fin (comprobado: un árbol con un archivo no terminaba en dos minutos).
if vistos is None:
vistos = set()
try:
clave = _id_carpeta(path)
if clave in vistos:
fallos.append(f"{path}: es un acceso directo a una carpeta ya recorrida; no se entra")
return
vistos = vistos | {clave}
except OSError:
pass
try:
it = os.scandir(path)
except OSError:
except OSError as ex:
fallos.append(f"{path}: no se pudo leer ({ex.strerror or ex})")
return
prog["actual"] = path
with it:
@@ -384,7 +422,12 @@ def escanear(ruta):
continue
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
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip())) or R.norm(nombre) in ("elements", "elemnts")
# una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
# medido el 16-sep-2026 sobre el inventario real, marcaba 1.713 archivos y 50,7 GB de
# material propio (clips de cámara de hasta 1 GB) como stock, que el respaldo excluye
# por defecto. Solo hereda la marca una carpeta con nombre completo de paquete de
# Envato; lo que de verdad se descargó se reconoce por el nombre del propio archivo.
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip()))
c2, n2 = contexto_dir(nombre, ctx, nivel)
a2, m2 = anio, mes
if c2 == "ANIO":
@@ -394,7 +437,10 @@ def escanear(ruta):
dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre])
dirs[e.path][3] = 1 if p else 0
dirs[e.path][4] = 1 if b else 0
walk(e.path, p, b, s, c2, n2, a2, m2)
if _es_enlace(e):
fallos.append(f"{e.path}: es un acceso directo a otra carpeta; no se entra")
continue
walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
else:
st = e.stat(follow_symlinks=False)
ext = os.path.splitext(nombre)[1].lower()
@@ -418,12 +464,26 @@ def escanear(ruta):
if padre == d:
break
d = padre
except OSError:
except OSError as ex:
fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})")
continue
try:
walk(raiz, False, False, False, "", "raiz", None, None)
prog["fallos"] = len(fallos)
con = db()
# Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de
# red se caía a media lectura, el inventario quedaba truncado y marcado como bueno, y a partir de
# ahí el plan, el respaldo y el convertidor trabajaban sobre datos parciales sin que nadie lo supiera.
anterior = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
if fallos and anterior and anterior["n"] and prog["n"] < anterior["n"] * 0.9:
con.close()
prog["estado"] = "error"
prog["error"] = (f"el escaneo no pudo leer {len(fallos)} sitios y solo encontró {prog['n']:,} archivos "
f"frente a {anterior['n']:,} del anterior: se conserva el inventario que ya había. "
f"Comprueba que la unidad esté conectada. Primero que falló: {fallos[0]}")
prog["fin"] = time.time()
return
with con:
# conservar el análisis de ffprobe de los archivos que no cambiaron
previo = {r["ruta"]: r for r in con.execute(
@@ -458,6 +518,8 @@ def escanear(ruta):
(raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"]))
con.close()
prog["estado"] = "listo"
if fallos:
prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})"
PLANES.pop(raiz, None) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco
except Exception as ex: # noqa
prog["estado"] = "error"
@@ -678,9 +740,14 @@ class Plan:
m, conflicto = R.detectar_mes(nombre)
if m:
canon = R.nombre_mes(m)
real, real_origen = self.mes_real(e.path)
discrepa = bool(real and anio and _discrepa_mes("%04d-%02d-01" % real, real_origen, anio, m))
if nombre != canon:
motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "")
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo, "media" if conflicto else "alta")
if discrepa:
motivo += f" · OJO: lo que hay dentro parece de {R.nombre_mes(real[1])}"
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo,
"media" if (conflicto or discrepa) else "alta")
self.plan_dir(e.path, "mes", anio, m)
return
if nivel == "raiz" or (nivel == "contenedor" and not anio):
@@ -780,6 +847,24 @@ class Plan:
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path))
self.agregar(e.path, self.abs_(destino), cub, "mover carpeta", motivo, conf)
def mes_real(self, ruta):
"""((año, mes), origen) que dicen los archivos de esta carpeta por su propio nombre, si están de
acuerdo. (None, "") cuando la fecha solo sale de la fecha de modificación, que no es de fiar."""
cnt = Counter()
origenes = Counter()
for f in archivos_bajo(self.con, ruta):
if f["es_basura"] or f["es_proxy"] or f["fecha_origen"] == "mtime":
continue
if f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "foto_camara", "celular_video", "audio_grabado"):
cnt[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
origenes[f["fecha_origen"]] += 1
if not cnt:
return None, ""
(a, m), n = cnt.most_common(1)[0]
if n < 3 or n / sum(cnt.values()) < 0.7:
return None, ""
return (a, m), origenes.most_common(1)[0][0]
def plan_grabacion(self, e, nivel, anio, mes, motivo):
"""Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto)."""
nombre = e.name
@@ -789,6 +874,7 @@ class Plan:
if not filas:
return
grupos = defaultdict(list)
desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta
for f in filas:
if f["es_basura"] or f["es_proxy"]:
continue
@@ -810,7 +896,14 @@ class Plan:
elif R.detectar_mes(seg)[0]:
mes_ruta = R.detectar_mes(seg)[0]
if anio and mes:
# La carpeta dice un mes, pero los archivos pueden decir otro. Medido el 16-sep-2026 sobre
# el material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes
# que anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre). Antes esto se movía
# con confianza alta y sin aviso; ahora se avisa y baja a media.
ay, am, fconf = anio, mes, "alta"
if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes):
fconf = "media"
desacuerdo[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
else:
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
por_nombre = f["fecha_origen"] != "mtime"
@@ -830,6 +923,16 @@ class Plan:
ay -= 1
grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"]))
etiqueta = "" if generico else R.etiqueta_grabacion(nombre)
aviso_fecha = ""
# hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar
# toda la carpeta sin marcar.
n_fechables = sum(1 for f in filas if not f["es_basura"] and not f["es_proxy"] and f["fecha_origen"] != "mtime")
if desacuerdo and sum(desacuerdo.values()) >= 3 and sum(desacuerdo.values()) >= 0.25 * max(1, n_fechables):
(da, dm), cuantos = desacuerdo.most_common(1)[0]
aviso_fecha = (f" · OJO: la carpeta dice {R.nombre_mes(mes)} pero {cuantos} de sus archivos son de "
f"{R.nombre_mes(dm)} según su propio nombre; comprueba antes de aplicar")
self.avisos.append(f"«{self.rel(e.path)}» dice {R.nombre_mes(mes)} de {anio} y el nombre de {cuantos} de "
f"sus archivos dice {R.nombre_mes(dm)}: se propone sin marcar para que lo revises.")
for (ay, am, fuente, fconf), archivos in grupos.items():
sub = fuente if not etiqueta else os.path.join(etiqueta, fuente)
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, sub=sub, nombre=limpiar_nombre(nombre))
@@ -837,7 +940,9 @@ class Plan:
if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos):
continue
mot = motivo + (" · repartido por fuente" if anio and mes else " · mes por la fecha de cada archivo" + (" (fecha de modificación)" if fconf == "baja" else ""))
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot, fconf, archivos=archivos)
marcado = False if aviso_fecha else None
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot + aviso_fecha, fconf,
archivos=archivos, marcado=marcado)
def plan_archivo(self, e, nivel, anio, mes, padre):
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()
@@ -1020,20 +1125,40 @@ def aplicar(raiz, filas):
APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz))
for f in filas:
APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "")
try:
if f.get("archivos"):
especiales = f.get("destinos_archivo") or {}
if f.get("archivos"):
# cada archivo va por su cuenta: antes, un clip abierto en Resolve cortaba el bucle y los
# ciento y pico restantes de esa misma fila no se intentaban, mientras el resumen decía
# que había fallado uno solo.
especiales = f.get("destinos_archivo") or {}
fallos_fila = 0
try:
if not especiales:
os.makedirs(f["destino"], exist_ok=True)
for a in f["archivos"]:
if os.path.exists(a):
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
else:
except Exception as ex: # noqa
errores.append(f"{f.get('origen_rel')}: no se pudo crear la carpeta destino: {ex}")
continue
for a in f["archivos"]:
if not os.path.exists(a):
continue
try:
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
except Exception as ex: # noqa
fallos_fila += 1
if fallos_fila <= 5:
errores.append(f"{os.path.basename(a)}: {ex}")
if fallos_fila > 5:
errores.append(f"{f.get('origen_rel')}: y otros {fallos_fila - 5} archivos que tampoco se pudieron mover")
if not fallos_fila:
hechas += 1
else:
try:
if os.path.exists(f["origen"]):
mover(f["origen"], f["destino"], lote, movidos)
hechas += 1
except Exception as ex: # noqa
errores.append(f"{f.get('origen_rel')}: {ex}")
hechas += 1
else:
errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)")
except Exception as ex: # noqa
errores.append(f"{f.get('origen_rel')}: {ex}")
APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
for f in filas:
@@ -1148,12 +1273,13 @@ def vocabulario(raiz):
t["bytes"] += c["bytes"] or 0
if len(t["rutas"]) < 4:
t["rutas"].append(rel)
if tokens:
if tokens and any(not t["cubeta"] for t in tokens.values()):
con = db()
resumen = _resumen_carpetas(con, raiz)
con.close()
for t in tokens.values():
if not t["cubeta"]:
_sugerir(con, raiz, t)
con.close()
_sugerir(resumen, raiz, t)
out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
return out
@@ -1162,27 +1288,64 @@ _ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición /
"imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"}
def _sugerir(con, raiz, t):
def _discrepa_mes(fecha, origen, anio, mes):
"""True si lo que dice el nombre del archivo no cuadra con el mes que anuncia la carpeta.
Los nombres de Blackmagic (A006_12171629) traen mes y día pero NO año: el año sale de la fecha de
modificación, que no es de fiar, así que en ese caso solo se compara el mes."""
if origen == "mtime":
return False # la fecha de modificación no prueba nada
ra, rm = int(fecha[:4]), int(fecha[5:7])
if origen == "nombre+mtime":
d = abs(rm - mes)
return min(d, 12 - d) >= 2 # solo el mes, y con dos meses de margen
return abs((ra * 12 + rm) - (anio * 12 + mes)) >= 2
def _clase_rol(rol):
return ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
"proyecto" if rol in ("proyecto", "diseno") else
"audio" if rol == "audio" else
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
"documento" if rol == "documento" else
"video" if rol in ("video", "celular_video") else "otro")
def _resumen_carpetas(con, raiz):
"""Para cada carpeta de la raíz, qué tipos de archivo tiene dentro contando subcarpetas.
Se calcula de una sola pasada sumando hacia arriba: antes se hacía una consulta por carpeta y
cada una recorría la tabla entera (abrir una raíz de 24.000 archivos tardaba 8,8 s; ahora 0,2 s)."""
agg = defaultdict(lambda: defaultdict(lambda: [0, 0]))
filas = con.execute("SELECT dir, rol, COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos "
"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol", (raiz,)).fetchall()
tope = os.path.normcase(os.path.normpath(raiz))
for f in filas:
k = _clase_rol(f["rol"])
d = os.path.normpath(f["dir"])
while True:
e = agg[os.path.normcase(d)][k]
e[0] += f["n"]
e[1] += f["b"]
if os.path.normcase(d) == tope:
break
padre = os.path.dirname(d)
if padre == d:
break
d = padre
return agg
def _sugerir(resumen, raiz, t):
"""Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre.
Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible)."""
import difflib
conteo = defaultdict(lambda: [0, 0])
total = [0, 0]
for rel in t["rutas"][:3]:
for f in archivos_bajo(con, os.path.join(raiz, rel)):
if f["es_basura"] or f["es_proxy"]:
continue
rol = f["rol"]
k = ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
"proyecto" if rol in ("proyecto", "diseno") else
"audio" if rol in ("audio",) else
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
"documento" if rol == "documento" else
"video" if rol in ("video", "celular_video") else "otro")
conteo[k][0] += 1
conteo[k][1] += f["bytes"]
total[0] += 1
total[1] += f["bytes"]
for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(os.path.join(raiz, rel))), {}).items():
conteo[k][0] += n
conteo[k][1] += b
total[0] += n
total[1] += b
orden = sorted(conteo.items(), key=lambda kv: -kv[1][1])
t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas"
sug, conf, por_que = "", "", ""
@@ -1195,7 +1358,7 @@ def _sugerir(con, raiz, t):
if total[0] >= 3 and orden:
k, (n, b) = orden[0]
frac = b / total[1] if total[1] else n / total[0]
if frac >= 0.7:
if frac >= 0.75:
por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS",
"documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k)
if por_cont:
@@ -1203,7 +1366,10 @@ def _sugerir(con, raiz, t):
por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}"
conf = "baja"
else:
sug, conf, por_que = por_cont, ("alta" if frac >= 0.9 and n >= 3 else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
# «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos
# los clientes: exige que sea contundente y que haya varias carpetas iguales.
seguro = frac >= 0.92 and n >= 8 and total[0] >= 10
sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que
@@ -1428,9 +1594,14 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
con = db()
limite = time.time() - int(meses) * 30.4 * 86400
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND bytes >= ? ORDER BY bytes DESC",
(raiz, int(min_mb) * 1024 * 1024)).fetchall()
en_cola = {r["ruta"]: r["estado"] for r in con.execute("SELECT ruta, estado FROM trabajos").fetchall()}
# 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()
# solo bloquea el clip lo que está a medias EN ESTE MISMO formato: un clip ya pasado a ProRes debe
# poder convertirse también a H.265, y uno que falló debe poder reintentarse.
en_cola = {r["ruta"]: r["estado"] for r in con.execute(
"SELECT ruta, estado FROM trabajos WHERE formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')",
(formato,)).fetchall()}
con.close()
out = []
for f in filas:
@@ -1468,7 +1639,8 @@ def salida_para(ruta, raiz, formato="h265_10bit"):
sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"]
if o.get("salida") == "espejo":
rel = os.path.relpath(base, raiz)
return os.path.join(raiz, "_Archivo", rel + fm["ext"])
# el sufijo también aquí: sin él, H.265 y H.264 del mismo clip iban al mismo archivo
return os.path.join(raiz, "_Archivo", rel + sufijo + fm["ext"])
return base + sufijo + fm["ext"]
@@ -1600,6 +1772,10 @@ def correr_ffmpeg(t):
return
if p.returncode != 0 or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
try:
os.remove(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
except OSError:
pass
return
os.replace(tmp, salida)
try:
@@ -1756,17 +1932,23 @@ class ErrorCola(ValueError):
self.datos = datos
_COLA_LOCK = threading.Lock()
def iniciar_cola():
if COLA["hilo"] and COLA["hilo"].is_alive():
return False
recuperar_huerfanas()
COLA["activa"] = True
COLA["cancelar"] = False
COLA["hechos"] = 0
COLA["inicio"] = time.time()
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
COLA["hilo"].start()
return True
# el servidor atiende cada petición en un hilo: sin candado, encolar con «iniciar» a la vez que
# alguien pulsa Reanudar arrancaba dos colas y dos ffmpeg sobre el mismo archivo temporal.
with _COLA_LOCK:
if COLA["hilo"] and COLA["hilo"].is_alive():
return False
recuperar_huerfanas()
COLA["activa"] = True
COLA["cancelar"] = False
COLA["hechos"] = 0
COLA["inicio"] = time.time()
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
COLA["hilo"].start()
return True
def verificar(ids):
@@ -2159,7 +2341,7 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru
por_tipo_res[it["tipo"]]["bytes"] += it["bytes"]
omit_raw.sort(key=lambda x: -x["bytes"])
return {
"cliente": cliente, "base": base, "n": len(items), "bytes": sum(i["bytes"] for i in items),
"cliente": cliente, "base": base, "destino_base": destino, "n": len(items), "bytes": sum(i["bytes"] for i in items),
"por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])],
"convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]},
"stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b},
@@ -2417,6 +2599,8 @@ class Manejador(BaseHTTPRequestHandler):
return True
def do_GET(self):
if self.path.startswith("/api/") and not self._es_local():
return self._json({"error": "petición no permitida"}, 403)
u = urlparse(self.path)
q = {k: v[0] for k, v in parse_qs(u.query).items()}
ruta = u.path
@@ -2524,8 +2708,8 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400)
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0))
PLANES_RESPALDO[raiz] = plan
CONFIG.setdefault("respaldo", {})["destino"] = destino
guardar_json(CONFIG_PATH, CONFIG)
# calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura
# reescribía config.json)
return self._json({k: v for k, v in plan.items() if k != "items"})
if ruta == "/api/respaldo/estado":
return self._json(RESPALDO)
@@ -2679,6 +2863,10 @@ class Manejador(BaseHTTPRequestHandler):
if ruta == "/api/abrir":
return self._json(abrir_carpeta(d.get("ruta", "")))
if ruta == "/api/respaldo/copiar":
_rp = PLANES_RESPALDO.get(os.path.normpath(d.get("raiz", "")))
if _rp and _rp.get("destino_base"):
CONFIG.setdefault("respaldo", {})["destino"] = _rp["destino_base"]
guardar_json(CONFIG_PATH, CONFIG)
raiz = os.path.normpath(d["raiz"])
plan = PLANES_RESPALDO.get(raiz)
if not plan:

Ver fichero

@@ -29,6 +29,7 @@ def norm(s: str) -> str:
MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
"agosto", "septiembre", "octubre", "noviembre", "diciembre"]
MESES_TITULO = [m.capitalize() for m in MESES]
# abreviaturas: se entienden, pero nunca con confianza alta (ver MESES_DUDOSOS)
MESES_ALIAS = {
"setiembre": "septiembre", "septiempre": "septiembre", "sept": "septiembre",
"sep": "septiembre", "ene": "enero", "feb": "febrero", "mar": "marzo",
@@ -38,6 +39,8 @@ MESES_ALIAS = {
"june": "junio", "july": "julio", "august": "agosto", "september": "septiembre",
"october": "octubre", "november": "noviembre", "december": "diciembre",
}
# «Mar», «May» o «Ago» pueden ser un nombre propio, no un mes: se proponen con confianza media
MESES_DUDOSOS = {"sep", "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "oct", "nov", "dic"}
def detectar_anio(nombre: str):
@@ -55,10 +58,12 @@ def detectar_mes(nombre: str):
return None, False
num = int(m.group(1)) if m.group(1) else None
palabra = m.group(2)
abreviada = palabra in MESES_DUDOSOS
palabra = MESES_ALIAS.get(palabra, palabra)
mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None
if mes_por_nombre:
return mes_por_nombre, (num is not None and num != mes_por_nombre)
# abreviada sin número («Mar») o número que no cuadra con el nombre: confianza media
return mes_por_nombre, (num is not None and num != mes_por_nombre) or (abreviada and num is None)
if num and 1 <= num <= 12 and not palabra:
return num, True # «3» solo puede ser marzo… o la pieza número 3: se propone con confianza media, no alta
return None, False
@@ -153,10 +158,14 @@ EXT = {
"programa": {".exe", ".msi", ".pkg", ".app", ".lnk"},
}
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"}
BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk", ".bak", ".ims", ".idx", ".dat"}
# solo temporales y cachés que un programa regenera solo. NO se ponen aquí extensiones de datos
# (.dat, .idx, .bak): son de plugins, subtítulos o copias que la gente guarda a propósito.
BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk"}
BASURA_DIRS = {"__macosx", "#recycle", ".synologyworkingdirectory", "@eadir", "media cache", "media cache files", "peak files"}
BASURA_DIRS_CONTIENE = ["almacenamiento automatico", "auto save", "autosave", "auto-save", "adobe premiere pro auto"]
PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media", "cache"}
# «cache» a secas no entra: es un nombre demasiado genérico para mandar la carpeta entera a _Para borrar
PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media",
"cache files", "render cache"}
def es_basura_archivo(nombre: str) -> bool:

Ver fichero

@@ -437,7 +437,7 @@
<!-- ============ HISTORIAL ============ -->
<section id="t-historial">
<div class="cab"><h2 class="v">Historial</h2><div class="der"><label class="mut" style="font-size:12.5px"><input type="checkbox" id="bitSoloRaiz" checked> solo esta carpeta</label></div></div>
<p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, con ejemplos de qué fue a dónde. <span class="q">?</span><span class="mas">La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p>
<p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, y <b>Deshacer</b> devuelve cada archivo a donde estaba. <span class="q">?</span><span class="mas">Si alguien movió un archivo después, ese se salta y se avisa. La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p>
<div id="papeleraLinea" class="hint" style="display:none"></div>
<div id="historial"></div>
</section>
@@ -503,10 +503,14 @@ function aviso(texto, tipo, enlace, detalle){
}
async function accion(btn, fn){
if (btn.disabled) return;
const antes = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="spin"></span>' + antes;
// se marca lo que pusimos nosotros: si la acción deja OTRA etiqueta (porque recalculó el plan, vació la
// lista o volvió a bloquear el botón), esa manda. Antes el finally la pisaba y quedaban botones
// habilitados con la cuenta vieja: «Aplicar todo lo marcado (23)» sobre un plan que ya no tenía 23.
const antes = btn.innerHTML, marca = '<span class="spin"></span>' + antes;
btn.disabled = true; btn.innerHTML = marca;
try { await fn(); }
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); }
finally { btn.disabled = false; btn.innerHTML = antes; }
finally { if (btn.innerHTML === marca) { btn.disabled = false; btn.innerHTML = antes; } }
}
// qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí
const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || ''));
@@ -578,9 +582,10 @@ function huella(E){
let SONDEO = null;
async function sondear(){ if (SONDEO) return; SONDEO = cargarEstado().catch(e => console.warn(e)).finally(() => { SONDEO = null; }); }
setInterval(sondear, 2500);
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; VOC = []; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
function limpiarTablas(){
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; estadoPlan();
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; PLAN_ABIERTOS = new Set();
$('#planIntro').innerHTML = 'Pulsa <b>Calcular</b> para ver, paso a paso, qué haría la app en esta carpeta.'; estadoPlan();
$('#optCards').innerHTML = ''; $('#anaEstado').textContent = ''; $('#convLista').innerHTML = '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>'; bloquear('btnEncolar', 'x'); $('#btnEncolar').textContent = 'Convertir';
$('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero');
$('#respCards').innerHTML = ''; $('#respTipos').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respRawLista').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respMuestra').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respEstado').textContent = ''; bloquear('btnRespCopiar', 'Calcula primero');
@@ -594,7 +599,7 @@ function tareasDe(E){
if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); }
if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); }
});
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)});
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
@@ -690,7 +695,8 @@ async function cargarCarpeta(){
$('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : '');
}
const lotes = CARPETA.lotes || [];
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span></div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && l.lote.startsWith('optimizar') ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
}
function queSeHizo(lote){
if (!lote) return 'Cambio';
@@ -886,7 +892,8 @@ function fraseDe(r){
function pintarPlan(){
if (!PLAN) { estadoPlan(); return; }
const f = ($('#filtroPlan').value || '').toLowerCase();
$('#planAvisos').innerHTML = (PLAN.avisos || []).map(a => `<div class="hint warn">${esc(a)}</div>`).join('');
const avs = PLAN.avisos || [];
$('#planAvisos').innerHTML = avs.length ? `<div class="hint warn"><b>${avs.length === 1 ? 'Revisa esto antes de aplicar' : 'Revisa estas ' + avs.length + ' cosas antes de aplicar'}:</b><ul style="margin:6px 0 0;padding-left:18px">${avs.map(a => `<li>${esc(a)}</li>`).join('')}</ul></div>` : '';
const porPaso = {}; PASOS_PLAN.forEach(p => porPaso[p.k] = []);
PLAN.filas.forEach(r => (porPaso[r.paso || 'resto'] = porPaso[r.paso || 'resto'] || []).push(r));
const tot = PLAN.filas.length, totB = PLAN.filas.reduce((a, r) => a + r.bytes, 0), dud = PLAN.filas.filter(r => r.confianza === 'baja').length;
@@ -912,11 +919,11 @@ function pintarPlan(){
$('#filtroPlan').oninput = pintarPlan;
function aplicarFilas(m, btn, titulo){
if (!m.length) return;
modal(titulo, `<p>Se mueven <b>${m.length}</b> cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra.</b> Es un cambio de sitio dentro del mismo disco. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no bloqueen archivos.</p>`, () => accion(btn, async () => {
modal(titulo, `<p>Se mueven <b>${m.length}</b> cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra</b> y se puede deshacer desde el Historial. Es un cambio de sitio dentro del mismo disco. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no bloqueen archivos.</p>`, () => accion(btn, async () => {
const destinos = {}; m.forEach(r => { if (r.editado) destinos[r.id] = r.destino_rel; });
const res = await api('/api/aplicar', {raiz: PLAN.raiz, ids: m.map(r => r.id), destinos});
PLAN = null; MAPA = null;
aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Ver historial', ir: () => irA('historial')});
aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(res.lote)});
if (res.errores.length) modal('Algunos no se pudieron mover', `<pre>${esc(res.errores.join('\n'))}</pre>`, null);
await cargarEstado(); await calcularPlan();
}));
@@ -1078,7 +1085,8 @@ async function cargarBit(){
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span><span></span></div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
}
$('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad'));
@@ -1132,7 +1140,7 @@ document.addEventListener('drop', seguro(async e => {
if (window.pywebview || (window.chrome && window.chrome.webview)) {
modal('Leyendo la carpeta…', `<p><span class="spin"></span> «${esc(nombre)}»</p>`, null);
const s = await api('/api/soltado?nombre=' + encodeURIComponent(nombre));
if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\/][^\/]*$/, '') : s.ruta);
if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\\/][^\\/]*$/, '') : s.ruta);
}
modal('Buscando la carpeta…', `<p><span class="spin"></span> El navegador solo entrega el nombre («${esc(nombre)}»). Buscando en los discos…</p>`, null);
const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre));