3.7.0: auditoría de Vía Sumapaz: «Calcular» ya no se queda gris, banco de material por temas, proxies sin original, y «Qué pesa» por dentro

- El botón «Calcular» (y «Reanudar» la cola, y «Volver a pintar») quedaba deshabilitado para siempre tras
  la primera vez: al cambiar de carpeta «Qué va a pasar» no calculaba nada.
- «material» suelto ya no ignora una carpeta; «No tocar» es un banco de material: los temas se quedan y,
  dentro, las grabaciones van por fecha y cámara, todo sin marcar.
- «Hacer de una vez» y la pasada de la noche ya no mueven basura ni proxies sin marcar; un proxy sin su
  original va sin marcar.
- «Qué pesa» entra nivel a nivel con el detalle de cada carpeta; repetidos, plan e Inicio mucho más rápidos;
  aviso cuando el tipo de trabajo de la carpeta no es el del cliente.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 06:55:38 -05:00
padre 0b15a6d788
commit cea36ea0c5
Se han modificado 7 ficheros con 694 adiciones y 44 borrados

Ver fichero

@@ -18,7 +18,7 @@ static/manrope.woff2 ← la tipografía, para no pedirle nada a nadie fuera de
WebView2Loader.dll ← la entrada a WebView2 (Microsoft, firmada); lo único que viaja y no es de Python WebView2Loader.dll ← la entrada a WebView2 (Microsoft, firmada); lo único que viaja y no es de Python
datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl
empaquetar.py ← arma el paquete: dist\OrganizadorLabstream\ con OrganizadorLabstream.exe y los dos zips empaquetar.py ← arma el paquete: dist\OrganizadorLabstream\ con OrganizadorLabstream.exe y los dos zips
probar.py ← 19 pruebas sin abrir nada · prueba_orden.py: el motor de orden y el convertidor, caso a caso · prueba_funcional.py: las 32 funciones · prueba_ventana.py: la ventana de verdad probar.py ← 19 pruebas sin abrir nada · prueba_orden.py: el motor de orden y el convertidor, caso a caso · prueba_funcional.py: las 33 funciones · prueba_ventana.py: la ventana de verdad
Iniciar.bat ← desde el código, con consola (para ver lo que imprime) Iniciar.bat ← desde el código, con consola (para ver lo que imprime)
``` ```
@@ -880,6 +880,55 @@ Pruebas: `prueba_orden.py` 24 casos (5 nuevos: pieza, bandeja, tarjeta con una c
que rechazar, versiones con ffprobe simulado, y la pasada automática con sus frenos); `prueba_funcional.py` que rechazar, versiones con ffprobe simulado, y la pasada automática con sus frenos); `prueba_funcional.py`
29 de 29 (3 nuevas, por la interfaz de verdad del servidor); `probar.py` 19 y `prueba_ventana.py` 18. 29 de 29 (3 nuevas, por la interfaz de verdad del servidor); `probar.py` 19 y `prueba_ventana.py` 18.
3.7.0, la auditoría de Vía Sumapaz. Munka: «creo que el escáner de archivos fue superficial», «no me sale la
forma de organización del material», «no hay reorganización y faltan funciones», y una captura de «Qué va a
pasar» con el botón «Calcular» gris y ningún plan. Lo medido el 14-oct-2026 sobre `Y:\Sumapaz` (76.559
archivos, 4,27 TB), solo leyendo:
- **El escaneo no era superficial**: un recorrido aparte del disco encontró los mismos 76.559 archivos y los
mismos 4,270 TB, hasta 11 niveles. Lo que fallaba era lo que la app hacía y enseñaba con ellos.
- **«Calcular» se quedaba gris para siempre** después del primer cálculo, en cualquier carpeta: al acabar, su
etiqueta pasa a «Calcular de nuevo», y la rutina que desbloquea los botones solo lo hacía si la etiqueta no
había cambiado. Al cambiar de carpeta, «Qué va a pasar» intentaba calcular, veía el botón bloqueado y no
hacía nada (con la lista de la carpeta anterior a la vista). Lo mismo «Reanudar» la cola después de
«Detener», y «Volver a pintar». Ahora un botón re-etiquetado se desbloquea salvo que alguien lo bloquee a
propósito, y la lista del plan se vacía al cambiar de carpeta.
- **El 85 % del disco quedaba fuera del orden**: «Material Vía (No tocar)», 3,6 TB, y dentro «Material
Importante», 2,6 TB, se ignoraban porque la palabra «material» suelta contaba como «dejar como está». Ahora
solo «Material» a secas es estructura. Y una carpeta cuyo nombre dice «No tocar» (o «No mover») es un
**banco de material por temas**: los temas se quedan donde están y, dentro de cada uno, las grabaciones van
por fecha de rodaje y cámara (`Lugares\Peajes\Drone` → `Lugares\Peajes\2024-03-12\DJI`); las carpetas
que solo dicen la cámara o la tarjeta («Drone», «URSA», «Pocket», «100MEDIA», «Grabado») se funden en su
tema. Todo lo que el plan propone dentro de un «No tocar» (sus proxies y su basura incluidos) va sin marcar
y no entra en «hacer de una vez»: en «Qué va a pasar» es un paso propio, «Banco de material», con «marcar
los N». Una fecha de antes de 2014 sacada del reloj de la cámara va como dudosa (250 clips de la Blackmagic
«del 28 de julio de 2008»). Otra carpeta se puede marcar como banco en «Por nombrar». En Sumapaz: 381
cambios, 10.902 grabaciones, 2,06 TB.
- **«Hacer de una vez» y la pasada de la noche movían basura y proxies sin marcar** (las demás filas sí
respetaban la marca). Ahora solo lo marcado.
- **Proxies sin su original**: de 12.645 proxies (449 GB), 126 no tenían el original en ningún sitio de la
carpeta (clips Fuji en una carpeta «Proxy», el proxy de un reel cuyo master ya no está, .LRF de dron sin su
MP4). Iban marcados a `_Para borrar`; ahora van aparte, sin marcar y con un aviso. Se emparejan por nombre,
también los que graba la Sony en `M4ROOT\SUB` (`C0001S03.MP4` es el proxy de `C0001.MP4`).
- **«Qué pesa» entra hasta el fondo**: antes solo el primer nivel, y «Material Vía» era un bloque gris de
3,64 TB sin nada dentro. Ahora se toca un bloque y se entra, nivel a nivel, con migas para volver; al lado,
qué guarda, de qué cámaras o grabadoras, de qué fechas, cuántos proxies y basura se pueden apartar, qué hace
la app con esa carpeta y cuántos cambios tiene en el plan (`/api/mapa/carpeta`). En Inicio: «Escaneo
completo: 76.559 archivos en 3.285 carpetas, hasta 11 niveles de profundidad. Ver qué hay dentro».
- **Más rápido**: Inicio tardaba 18 s en SUMAPAZ y «Qué hay» 21 s, casi todo comprobando en el disco de red
que existía cada uno de 30.963 archivos para buscar repetidos. Ahora solo se comprueban los que pueden
formar grupo, en paralelo, y el resultado se guarda hasta que algo se mueve: 2,3 s la primera vez, nada
las siguientes; Inicio, 4,4 s. Mientras carga, Inicio dice «Leyendo la carpeta» y «Qué hay» «Leyendo el
inventario» (antes decía «Esta carpeta todavía no está escaneada»). El plan de SUMAPAZ, de 6,5 s a 2,4 s
(apartar la basura comparaba cada archivo con cada carpeta ya cubierta: 5 millones de comparaciones).
- **El tipo de trabajo no cuadraba**: Vía Sumapaz estaba guardado como «proyecto» y su carpeta como
«contenido», sin que nada lo dijera. Inicio lo dice y deja elegir uno; elegirlo iguala los dos
(`/api/raiz/tipo`).
Pruebas: `prueba_orden.py` 34 casos (uno nuevo: el banco de material, de punta a punta, con «No tocar»,
reloj sin poner y proxies sin original); `prueba_funcional.py` 33 de 33 (una nueva: «Qué pesa» por dentro y el
tipo de trabajo); `probar.py` 19 y `prueba_ventana.py` 18.
3.6.0, Inicio dice lo que ya se hizo. Munka pidió auditar «mover y reorganizar»: «si hago el cambio debería 3.6.0, Inicio dice lo que ya se hizo. Munka pidió auditar «mover y reorganizar»: «si hago el cambio debería
decir que el cambio se hizo y no mandarme para hacerlo y mostrarme lo que pasaría o lo que ahorraría cuando ya decir que el cambio se hizo y no mandarme para hacerlo y mostrarme lo que pasaría o lo que ahorraría cuando ya
lo había movido». Reproducido el 14-oct-2026 sobre una carpeta de ensayo: el motor mueve bien (plan → aplicar → lo había movido». Reproducido el 14-oct-2026 sobre una carpeta de ensayo: el motor mueve bien (plan → aplicar →

Ver fichero

@@ -191,7 +191,9 @@ def filas_elegidas(plan_filas, dec):
for f in plan_filas: for f in plan_filas:
c = f["cubeta"] c = f["cubeta"]
if c in ("BASURA", "PROXY"): if c in ("BASURA", "PROXY"):
if dec.get("limpieza", "papelera") == "papelera": # también aquí, solo lo marcado: un proxy sin su original o lo que está dentro de una carpeta
# «No tocar» van sin marcar, y «hacer de una vez» (y la pasada de la noche) los movían igual
if dec.get("limpieza", "papelera") == "papelera" and f["marcado"]:
out.append(f) out.append(f)
elif c in ("GRABACION", "CELULAR", "FOTOS"): elif c in ("GRABACION", "CELULAR", "FOTOS"):
# solo lo marcado: antes entraba todo el material aunque el plan lo diera por dudoso (el mes # solo lo marcado: antes entraba todo el material aunque el plan lo diera por dudoso (el mes

Ver fichero

@@ -42,7 +42,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__] actualizador.O = sys.modules[__name__]
VERSION = "3.6.0" VERSION = "3.7.0"
BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es # Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
@@ -1000,6 +1000,40 @@ def _fecha_de_camara(f):
_RX_DIA = re.compile(r"^\d{2}- (" + "|".join(R.DIAS) + r")$", re.I) _RX_DIA = re.compile(r"^\d{2}- (" + "|".join(R.DIAS) + r")$", re.I)
_RX_FECHA_DIA = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# en un banco de material, lo que se reordena por fecha y cámara: el material grabado, no lo montado
_ROLES_BANCO = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video")
# carpetas que solo dicen con qué se grabó (además de las de R.FUENTE_PALABRAS)
_CAMARAS_BANCO = {"fuji", "gopro", "go pro", "mavic", "osmos", "insta360", "iphone", "ronin", "fx3", "fx6", "a7", "a7s"}
_NOMBRES_FUENTE = {v.upper() for v in R.FUENTE_CARPETA.values()} | {"AUDIO", "OTROS", "FOTOS"}
def _solo_fuente(seg):
"""True si la carpeta solo dice la cámara, la tarjeta, el día o que son repetidos: no es un tema."""
s = (seg or "").strip()
n = R.norm(s)
return (n in R.FUENTE_PALABRAS or n in _CAMARAS_BANCO or R.es_carpeta_de_tarjeta(s) or bool(_RX_DIA.match(s))
or bool(_RX_FECHA_DIA.match(s)) or s.upper() in _NOMBRES_FUENTE or n == R.norm(R.DUPLICADOS))
def _bajo_alguna(ruta, conjunto):
"""True si `ruta` o alguna carpeta por encima de ella está en `conjunto` (rutas en minúsculas)."""
d = os.path.normpath(ruta).lower()
while True:
if d in conjunto:
return True
p = os.path.dirname(d)
if p == d:
return False
d = p
def _tallo(nombre):
"""«C0001_Proxy.mov», «C0001S03.MP4» (el proxy que graba la Sony en M4ROOT/SUB) y «C0001.MP4» -> «c0001»:
para emparejar un proxy con su original."""
t = re.sub(r"[ _.-]?proxy$", "", os.path.splitext(nombre or "")[0].lower())
m = re.match(r"^(c\d{4})s\d{2}$", t)
return m.group(1) if m else t
# ========================================================================== # ==========================================================================
@@ -1014,6 +1048,9 @@ class Plan:
self.dicc = DICC["carpetas"] self.dicc = DICC["carpetas"]
self.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)] self.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)]
self.filas = [] self.filas = []
self.bancos = [] # (ruta normcase + sep, nombre, protegido): los bancos de material
self._carpeta_existe = {} # destino -> si ya existe en el disco (para no preguntar dos veces)
self._bytes = None # ruta -> bytes, para partir filas de archivos
self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> archivos
self.con = db() self.con = db()
self.avisos = [] self.avisos = []
@@ -1076,8 +1113,14 @@ class Plan:
return r return r
return None return None
def _hay_carpeta(self, d):
d = os.path.normcase(os.path.normpath(d))
if d not in self._carpeta_existe:
self._carpeta_existe[d] = os.path.isdir(d)
return self._carpeta_existe[d]
def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None, def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None,
marcado=None, kw_destinos=None): marcado=None, kw_destinos=None, paso=None):
destino = os.path.normpath(destino) destino = os.path.normpath(destino)
if archivos is None and os.path.normpath(origen).lower() == destino.lower(): if archivos is None and os.path.normpath(origen).lower() == destino.lower():
# el mismo sitio con otras mayúsculas solo tiene sentido como renombrado («10- octubre» -> # el mismo sitio con otras mayúsculas solo tiene sentido como renombrado («10- octubre» ->
@@ -1101,7 +1144,8 @@ class Plan:
# solo los primeros: cada comprobación es una consulta al disco de red, y para avisar # solo los primeros: cada comprobación es una consulta al disco de red, y para avisar
# basta con encontrar uno # basta con encontrar uno
choques = [os.path.basename(a[0]) for a in archivos[:40] choques = [os.path.basename(a[0]) for a in archivos[:40]
if os.path.isfile(especiales.get(a[0]) or os.path.join(destino, os.path.basename(a[0])))] if self._hay_carpeta(os.path.dirname(especiales.get(a[0]) or os.path.join(destino, "x")))
and os.path.isfile(especiales.get(a[0]) or os.path.join(destino, os.path.basename(a[0])))]
if choques: if choques:
aviso_colision = (f" · OJO: en el destino ya hay archivos con el mismo nombre " aviso_colision = (f" · OJO: en el destino ya hay archivos con el mismo nombre "
f"(«{choques[0]}»): esos no se moverían") f"(«{choques[0]}»): esos no se moverían")
@@ -1128,8 +1172,8 @@ class Plan:
"cosmetico": marcado is False and accion == "renombrar", "cosmetico": marcado is False and accion == "renombrar",
# en qué paso del recorrido se aplica: 1 limpiar (basura/proxies), 2 por fecha (años, meses, grabaciones, # en qué paso del recorrido se aplica: 1 limpiar (basura/proxies), 2 por fecha (años, meses, grabaciones,
# piezas), 3 el resto (exportados, proyectos, marca, música, dudosos) # piezas), 3 el resto (exportados, proyectos, marca, música, dudosos)
"paso": "limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else "paso": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else
"fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto", "fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"),
}) })
def suelto(self, fila, cubeta, destino_rel, motivo, confianza): def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
@@ -1185,6 +1229,8 @@ class Plan:
self.plan_auditoria() self.plan_auditoria()
self.volcar_sueltos() self.volcar_sueltos()
self.plan_basura() self.plan_basura()
self.revisar_proxies()
self.proteger_bancos()
self.con.close() self.con.close()
# primero las filas de archivos sueltos (rutas exactas), luego carpetas de más profunda a menos, # primero las filas de archivos sueltos (rutas exactas), luego carpetas de más profunda a menos,
# para que un renombrado de mes no deje rutas viejas en filas que aún no se aplicaron # para que un renombrado de mes no deje rutas viejas en filas que aún no se aplicaron
@@ -1383,6 +1429,9 @@ class Plan:
cub, motivo = R.cubeta_carpeta(nombre, self.dicc) cub, motivo = R.cubeta_carpeta(nombre, self.dicc)
if cub == "IGNORAR": if cub == "IGNORAR":
return return
if cub == "BANCO":
self.plan_banco(e)
return
if cub == "CONTENEDOR": if cub == "CONTENEDOR":
m_anio = re.search(r"(20\d\d)", nombre) m_anio = re.search(r"(20\d\d)", nombre)
if self.est.fechas_dentro(e.path): if self.est.fechas_dentro(e.path):
@@ -1488,6 +1537,153 @@ class Plan:
return None, "" return None, ""
return (a, m), origenes.most_common(1)[0][0] return (a, m), origenes.most_common(1)[0][0]
def plan_banco(self, e):
"""Un banco de material por temas («Material Vía (No tocar)\\Lugares\\Peajes»): el cliente lo consulta
por tema, así que los temas se quedan donde están. Lo que se ordena son las grabaciones de dentro de
cada tema: por fecha de rodaje y por cámara, como en cualquier 00_Grabacion. «Lugares\\Peajes\\Drone»
pasa a «Lugares\\Peajes\\2024-03-12\\DJI». Las carpetas que solo dicen la cámara, la tarjeta o el día
(«Drone», «URSA», «Pocket», «100MEDIA», «Grabado») no son un tema: se funden en el tema de encima.
Medido el 14-oct-2026 en Vía Sumapaz: el banco eran 3,6 TB, 50.076 archivos, con temas como Lugares,
Social, Ambiental o Unidades Funcionales; tratado como piezas sueltas, el plan quería llevar cada tema
entero a un mes inventado. Si el nombre dice «No tocar», todo lo de dentro va sin marcar."""
protegido = R.es_no_tocar(e.name)
self.bancos.append((os.path.normcase(os.path.normpath(e.path)) + os.sep, e.name, protegido))
grupos = defaultdict(list)
for f in archivos_bajo(self.con, e.path):
if f["es_basura"] or f["es_proxy"] or f["rol"] not in _ROLES_BANCO or not f["fecha"]:
continue
rel = os.path.relpath(f["dir"], e.path)
segs = [] if rel == "." else rel.split(os.sep)
repetido = False
while segs and _solo_fuente(segs[-1]):
repetido = repetido or R.norm(segs[-1]) == R.norm(R.DUPLICADOS)
segs.pop()
fuente = R.FUENTE_CARPETA.get(f["fuente"]) or ("AUDIO" if f["rol"] == "audio_grabado" else "OTROS")
fiable = f["fecha_origen"] != "mtime" or _fecha_de_camara(f)
# una cámara con el reloj sin poner fecha en 2008 o 2009: medido en Vía Sumapaz, 250 clips de la
# Blackmagic «del 28 de julio de 2008» y 100 del dron «del 19 de enero de 2009»
reloj = f["fecha_origen"] != "nombre" and int(f["fecha"][:4]) < 2014
destino = os.path.join(e.path, *segs, f["fecha"][:10], fuente, *([R.DUPLICADOS] if repetido else []))
if os.path.normcase(os.path.normpath(destino)) == os.path.normcase(os.path.normpath(f["dir"])):
continue
tema = segs[-1] if segs else e.name
por_que = "reloj" if reloj else "" if fiable else "mtime"
grupos[(f["dir"], destino, tema, "media" if fiable and not reloj else "baja", por_que)].append((f["ruta"], f["bytes"]))
for (origen, destino, tema, conf, por_que), archivos in sorted(grupos.items()):
self.agregar(origen, destino, "GRABACION", "mover archivos",
f"banco de material «{e.name}»: el tema «{tema}» se queda; dentro, por fecha de rodaje y cámara"
+ {"": "", "mtime": " (la fecha sale de la fecha de modificación: puede fallar)",
"reloj": f" (la fecha, {next((x for x in destino.split(os.sep) if _RX_FECHA_DIA.match(x)), '')}, parece de una cámara con el reloj sin poner: no es de fiar)"}[por_que],
conf, archivos=archivos, marcado=False if protegido else None, paso="banco")
self.filas[-1]["no_tocar"] = protegido
def _partir(self, f, aparte, motivo, confianza=None, no_tocar=False):
"""Saca de una fila de archivos los que cumplen `aparte` a una fila propia, sin marcar. Si son todos,
la fila entera queda sin marcar. Devuelve la fila nueva, si hace falta una."""
sep = [a for a in f["archivos"] if aparte(a)]
if not sep:
return None
if len(sep) == len(f["archivos"]):
f["marcado"] = False
f["no_tocar"] = f.get("no_tocar") or no_tocar
f["confianza"] = confianza or f["confianza"]
if motivo not in f["motivo"]:
f["motivo"] += motivo
return None
if self._bytes is None:
self._bytes = {r["ruta"]: r["bytes"] or 0 for r in self.con.execute("SELECT ruta, bytes FROM archivos WHERE raiz = ?", (self.raiz,))}
quedan = [a for a in f["archivos"] if not aparte(a)]
nueva = dict(f)
destinos = dict(f.get("destinos_archivo") or {})
for fila, lista in ((f, quedan), (nueva, sep)):
fila["archivos"] = lista
fila["n"] = len(lista)
fila["bytes"] = sum(self._bytes.get(a, 0) for a in lista)
if destinos:
fila["destinos_archivo"] = {a: destinos[a] for a in lista if a in destinos}
nueva.update(marcado=False, confianza=confianza or f["confianza"], motivo=f["motivo"] + motivo, cosmetico=False,
no_tocar=f.get("no_tocar") or no_tocar)
return nueva
def revisar_proxies(self):
"""Un proxy sin su original puede ser la única copia que queda de ese clip. Medido el 14-oct-2026 en Vía
Sumapaz: de 12.645 proxies (449 GB), 126 (4,4 GB) no tenían el original en ningún sitio de la carpeta
(clips Fuji de una carpeta «Proxy», el proxy de un reel cuyo master ya no está, .LRF de un dron sin su
MP4). Esos van aparte y sin marcar; los demás siguen yendo a _Para borrar como siempre."""
filas = [f for f in self.filas if f["cubeta"] == "PROXY"]
if not filas:
return
originales = {_tallo(r["nombre"]) for r in self.con.execute(
"SELECT nombre FROM archivos WHERE raiz = ? AND es_proxy = 0 AND es_basura = 0", (self.raiz,))}
def huerfano(ruta):
n = os.path.basename(ruta)
ext = os.path.splitext(n)[1].lower()
return (ext in R.EXT["video"] or ext == ".lrf") and _tallo(n) not in originales
bajo = defaultdict(list) # carpeta (minúsculas) -> los proxies que tiene debajo
tope = self.raiz.lower()
for r in self.con.execute("SELECT ruta, nombre, bytes FROM archivos WHERE raiz = ? AND es_proxy = 1", (self.raiz,)).fetchall():
d = os.path.dirname(r["ruta"]).lower()
while True:
bajo[d].append(r)
p = os.path.dirname(d)
if d == tope or p == d:
break
d = p
nuevas, n_sin, b_sin = [], 0, 0
for f in filas:
if f["archivos"]:
sin = [a for a in f["archivos"] if huerfano(a)]
if not sin:
continue
nueva = self._partir(f, huerfano, f" · OJO: {_plural(len(sin), 'proxy no tiene', 'proxies no tienen')} su original en la "
f"carpeta (p. ej. «{os.path.basename(sin[0])}»): puede ser la única copia; míralos antes", "baja")
if nueva:
nuevas.append(nueva)
n_sin += len(sin)
else:
sin = [r for r in bajo.get(os.path.normpath(f["origen"]).lower(), []) if huerfano(r["ruta"])]
if not sin:
continue
f["motivo"] += (f" · OJO: {_plural(len(sin), 'proxy no tiene', 'proxies no tienen')} su original en la carpeta "
f"(p. ej. «{sin[0]['nombre']}»): puede ser la única copia; míralos antes")
f["confianza"], f["marcado"] = "baja", False
n_sin += len(sin)
b_sin += sum(r["bytes"] or 0 for r in sin)
self.filas += nuevas
if n_sin:
self.avisos.append(f"{_plural(n_sin, 'proxy no tiene', 'proxies no tienen')} su original en ningún sitio de esta carpeta. "
f"Pueden ser la única copia de esos clips: van sin marcar, para que los mires antes de apartarlos.")
def proteger_bancos(self):
"""Todo lo que el plan propone dentro de una carpeta «No tocar» (sus proxies, su basura) va sin marcar."""
prot = [(p, nombre) for p, nombre, protegido in self.bancos if protegido]
if not prot:
return
def de_cual(ruta):
rc = os.path.normcase(os.path.normpath(ruta))
return next((nombre for p, nombre in prot if rc.startswith(p)), None)
nuevas = []
for f in list(self.filas):
if f.get("paso") == "banco":
continue
if f["archivos"]:
dentro = [a for a in f["archivos"] if de_cual(a)]
if dentro:
nueva = self._partir(f, lambda a: de_cual(a) is not None,
f" · dentro de «{de_cual(dentro[0])}», que dice «No tocar»: márcalo tú si quieres", no_tocar=True)
if nueva:
nuevas.append(nueva)
else:
nombre = de_cual(f["origen"])
if nombre:
f["marcado"] = False
f["no_tocar"] = True
f["motivo"] += f" · dentro de «{nombre}», que dice «No tocar»: márcalo tú si quieres"
self.filas += nuevas
def plan_grabacion(self, e, nivel, anio, mes, motivo): 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).""" """Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto)."""
nombre = e.name nombre = e.name
@@ -1763,11 +1959,16 @@ class Plan:
# aquí van las que están más adentro (dentro de piezas) y los archivos sueltos de basura # aquí van las que están más adentro (dentro de piezas) y los archivos sueltos de basura
carpetas = con.execute("SELECT * FROM carpetas WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall() carpetas = con.execute("SELECT * FROM carpetas WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall()
cubiertas = [] cubiertas = []
# se mira subiendo por la ruta de cada uno, no recorriendo la lista entera: en Vía Sumapaz (13.899
# archivos de proxy o basura, 540 carpetas cubiertas) eran 5 millones de comparaciones y 13 s
cub_set = set()
origenes = {os.path.normpath(f["origen"]).lower() for f in self.filas}
for c in sorted(carpetas, key=lambda c: len(c["ruta"])): for c in sorted(carpetas, key=lambda c: len(c["ruta"])):
if any(c["ruta"].lower().startswith(p.lower() + os.sep) for p in cubiertas): if _bajo_alguna(os.path.dirname(c["ruta"]), cub_set):
continue continue
if any(os.path.normpath(f["origen"]).lower() == c["ruta"].lower() for f in self.filas): if c["ruta"].lower() in origenes:
cubiertas.append(c["ruta"]) cubiertas.append(c["ruta"])
cub_set.add(c["ruta"].lower())
continue continue
if any(R.norm(self.rel(c["ruta"])).startswith(R.norm(p)) for p in R.PAPELERAS): if any(R.norm(self.rel(c["ruta"])).startswith(R.norm(p)) for p in R.PAPELERAS):
continue continue
@@ -1775,10 +1976,11 @@ class Plan:
self.agregar(c["ruta"], self.abs_(destino_plantilla(self.tipo, cub, rel=self.rel(c["ruta"]))), cub, "papelera", self.agregar(c["ruta"], self.abs_(destino_plantilla(self.tipo, cub, rel=self.rel(c["ruta"]))), cub, "papelera",
"proxies: se regeneran" if cub == "PROXY" else "caché / autoguardado", "alta") "proxies: se regeneran" if cub == "PROXY" else "caché / autoguardado", "alta")
cubiertas.append(c["ruta"]) cubiertas.append(c["ruta"])
cub_set.add(c["ruta"].lower())
sueltos = con.execute("SELECT * FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall() sueltos = con.execute("SELECT * FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall()
grupos = defaultdict(list) grupos = defaultdict(list)
for f in sueltos: for f in sueltos:
if any(f["dir"].lower() == p.lower() or f["dir"].lower().startswith(p.lower() + os.sep) for p in cubiertas): if _bajo_alguna(f["dir"], cub_set):
continue continue
if any(R.norm(self.rel(f["dir"])).startswith(R.norm(p)) for p in R.PAPELERAS): if any(R.norm(self.rel(f["dir"])).startswith(R.norm(p)) for p in R.PAPELERAS):
continue continue
@@ -2329,7 +2531,7 @@ def vocabulario(raiz):
apunta(h, p[1], "", "", "está numerada como una pieza, pero dentro guarda carpetas de varias fechas") apunta(h, p[1], "", "", "está numerada como una pieza, pero dentro guarda carpetas de varias fechas")
continue # una pieza: no se toca por dentro continue # una pieza: no se toca por dentro
cub, mot = R.cubeta_carpeta(nombre, dicc) cub, mot = R.cubeta_carpeta(nombre, dicc)
if cub == "IGNORAR": if cub in ("IGNORAR", "BANCO"):
apunta(h, nombre, cub, mot) apunta(h, nombre, cub, mot)
continue continue
if cub == "CONTENEDOR": if cub == "CONTENEDOR":
@@ -2456,7 +2658,7 @@ def _sugerir(est, resumen, raiz, t):
# c) por el nombre: se parece a una palabra del diccionario # c) por el nombre: se parece a una palabra del diccionario
dicc = DICC["carpetas"] dicc = DICC["carpetas"]
cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82) cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82)
if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR", "GRABACION"): if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR", "GRABACION", "BANCO"):
sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»" sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»"
# d) por el contenido (manda si es contundente) # d) por el contenido (manda si es contundente)
if total[0] >= 3 and orden: if total[0] >= 3 and orden:
@@ -4176,7 +4378,56 @@ def _mismas_tomas(candidatos, tope=400):
return grupos return grupos
_DUP_CACHE = {}
def duplicados(raiz, minimo=5_000_000, calidades=False): def duplicados(raiz, minimo=5_000_000, calidades=False):
"""Los repetidos de una carpeta, guardados hasta que algo se mueva en ella (o pasen 10 minutos): Inicio,
«Qué hay» y el diagnóstico los piden cada vez, y en Vía Sumapaz (76.559 archivos) costaban 22 s cada vez."""
k = (os.path.normcase(os.path.normpath(raiz)), minimo, bool(calidades))
marca = (sum(v for kk, v in PLAN_GEN.items() if kk.lower() == k[0]), _ts_escaneo(raiz))
c = _DUP_CACHE.get(k)
if c and c[0] == marca and time.time() - c[1] < 600:
return c[2]
r = _duplicados(raiz, minimo, calidades)
_DUP_CACHE[k] = (marca, time.time(), r)
return r
def _ts_escaneo(raiz):
con = db()
f = con.execute("SELECT ts FROM escaneos WHERE raiz = ?", (os.path.normpath(raiz),)).fetchone()
con.close()
return f["ts"] if f else 0
def _solo_existentes(filas):
"""Quita los que ya no están en el disco, pero solo pregunta por los que pueden formar grupo (mismo nombre
y peso, el mismo clip en dos formatos, o con dos pesos), y en paralelo. Medido el 14-oct-2026 en Vía
Sumapaz: comprobar uno a uno los 30.963 archivos de más de 5 MB por la red tardaba 20 de 22 segundos."""
por_nb = Counter((f["nombre"].lower(), f["bytes"]) for f in filas)
exts, pesos = defaultdict(set), defaultdict(set)
for f in filas:
e = (f["ext"] or "").lower()
if e in _EXT_VIDEO_DUP:
exts[os.path.splitext(f["nombre"])[0].lower()].add(e)
pesos[f["nombre"].lower()].add(f["bytes"])
def puede(f):
if por_nb[(f["nombre"].lower(), f["bytes"])] > 1:
return True
return (f["ext"] or "").lower() in _EXT_VIDEO_DUP and (
len(exts[os.path.splitext(f["nombre"])[0].lower()]) > 1 or len(pesos[f["nombre"].lower()]) > 1)
dudosos = [f["ruta"] for f in filas if puede(f)]
if not dudosos:
return filas
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=24) as ex:
existe = dict(zip(dudosos, ex.map(os.path.exists, dudosos)))
return [f for f in filas if existe.get(f["ruta"], True)]
def _duplicados(raiz, minimo=5_000_000, calidades=False):
"""Grupos de archivos repetidos. Tres clases: """Grupos de archivos repetidos. Tres clases:
identico = mismo nombre y mismo tamaño en sitios distintos. identico = mismo nombre y mismo tamaño en sitios distintos.
version = mismo nombre con otra extensión de video (p. ej. el MP4 y el MOV del mismo clip): puede version = mismo nombre con otra extensión de video (p. ej. el MP4 y el MOV del mismo clip): puede
@@ -4189,7 +4440,7 @@ def duplicados(raiz, minimo=5_000_000, calidades=False):
"SELECT ruta, dir, nombre, ext, bytes, rol FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0", "SELECT ruta, dir, nombre, ext, bytes, rol FROM archivos WHERE raiz = ? AND bytes >= ? AND es_proxy = 0 AND es_basura = 0",
(os.path.normpath(raiz), minimo)).fetchall()] (os.path.normpath(raiz), minimo)).fetchall()]
con.close() con.close()
filas = [f for f in filas if _fuera_de_dup(f["ruta"]) and os.path.exists(f["ruta"])] filas = _solo_existentes([f for f in filas if _fuera_de_dup(f["ruta"])])
grupos, usados = [], set() grupos, usados = [], set()
por_nombre = {} por_nombre = {}
for f in filas: for f in filas:
@@ -4709,7 +4960,7 @@ def mapa_estructura(tipo, raiz=None):
plantilla = pl.get(cub, "") plantilla = pl.get(cub, "")
n, b = hay.get(cub, [0, 0]) n, b = hay.get(cub, [0, 0])
cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla, cubetas.append({"clave": cub, "titulo": titulo, "plantilla": plantilla,
"asignable": cub not in ("CONTENEDOR", "IGNORAR"), "asignable": cub not in ("CONTENEDOR", "IGNORAR", "BANCO"),
"color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]), "color": R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]),
"ejemplos": ejemplos.get(cub, []), "ejemplos": ejemplos.get(cub, []),
"defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""), "defecto": R.PLANTILLAS.get(tipo, {}).get(cub, ""),
@@ -5810,6 +6061,18 @@ def acciones(raiz):
cifra="", detalle="El inventario de esta carpeta es de la versión anterior. Solo lee: no mueve nada.", cifra="", detalle="El inventario de esta carpeta es de la versión anterior. Solo lee: no mueve nada.",
ir="escanear") ir="escanear")
# --- el tipo de trabajo de la carpeta no es el del cliente ---
# Vía Sumapaz estaba guardado como «proyecto» y su carpeta como «contenido»: el plan, el mapa y «Organizar
# carpetas» usan el de la carpeta, y nada decía que no cuadraban.
tc = ((CONFIG.get("clientes") or {}).get(r["cliente"]) or {}).get("tipo")
tr = r.get("tipo", "contenido")
if tc and tc != tr and tc in PLANTILLAS:
nom = lambda t: (R.TIPOS_CLIENTE.get(t) or t).split(" · ")[0] # noqa
añadir(clave="tipo", titulo="Elegir cómo se ordena esta carpeta", boton="Elegir",
que=f"{r['cliente']} está guardado como «{nom(tc)}», pero esta carpeta se ordena como «{nom(tr)}».",
cifra="", detalle="El plan, el mapa y «Organizar carpetas» usan el de la carpeta. Elige uno y quedan los dos iguales.",
ir="tipo", tipos=[tr, tc])
# --- la bandeja de entrada: material nuevo esperando a que lo repartan --- # --- la bandeja de entrada: material nuevo esperando a que lo repartan ---
ent = entrada(raiz) ent = entrada(raiz)
if ent["n"]: if ent["n"]:
@@ -5827,7 +6090,7 @@ def acciones(raiz):
añadir(clave="limpiar", titulo="Apartar basura y proxies", boton="Ver y apartar", añadir(clave="limpiar", titulo="Apartar basura y proxies", boton="Ver y apartar",
que="Archivos de sistema y proxies, que Resolve y Premiere regeneran solos.", que="Archivos de sistema y proxies, que Resolve y Premiere regeneran solos.",
cifra=f"{_plural(fila['n'], 'archivo', 'archivos')} · {_gb(fila['b'])}", cifra=f"{_plural(fila['n'], 'archivo', 'archivos')} · {_gb(fila['b'])}",
detalle=f"Van a «{R.PAPELERA}» dentro de la misma carpeta. No se borra nada.", detalle=f"Van a «{R.PAPELERA}» dentro de la misma carpeta. No se borra nada." + _no_tocar_dentro(con, raiz),
ir="plan", paso="limpiar") ir="plan", paso="limpiar")
elif vieja: elif vieja:
añadir(clave="limpiar", titulo="Juntar las dos papeleras", boton="Ver y juntar", añadir(clave="limpiar", titulo="Juntar las dos papeleras", boton="Ver y juntar",
@@ -5898,6 +6161,33 @@ def acciones(raiz):
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera", añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera",
detalle=(hecho_det + " " if hecho_det else "") + "Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave) detalle=(hecho_det + " " if hecho_det else "") + "Hay que calcular el plan para saber si queda algo.", ir="plan", paso=clave)
# --- el banco de material: los temas se quedan; dentro, por fecha y cámara ---
bancos = _bancos_de(con, raiz)
if bancos:
b0 = bancos[0]
nombre_b = b0["nombre"]
prot = any(b["protegido"] for b in bancos)
que_b = "Los temas se quedan como están; dentro de cada uno, las grabaciones por fecha de rodaje y cámara."
nota_b = ("Su nombre dice «No tocar»: la app lo propone todo sin marcar y no entra en «hacer de una vez». "
"Revísalo en «Qué va a pasar» y marca lo que quieras." if prot else "")
if plan:
fs = [f for f in plan["filas"] if f.get("paso") == "banco"]
marc = [f for f in fs if f.get("marcado")]
if fs:
añadir(clave="banco", titulo=f"Ordenar el banco de material «{nombre_b}»" + (f" y {len(bancos) - 1} más" if len(bancos) > 1 else ""), boton="Ver", que=que_b,
estado="toca" if marc else "espera",
cifra=f"{_plural(len(fs), 'cambio', 'cambios')} · {_gb(sum(f['bytes'] for f in fs))}",
detalle=nota_b or f"{len(marc)} marcados.", ir="plan", paso="banco")
else:
añadir(clave="banco", titulo=f"Banco de material «{nombre_b}»", estado="hecho", que=que_b, cifra="",
detalle="Sus grabaciones ya están por fecha y cámara.")
else:
añadir(clave="banco", titulo=f"Ordenar el banco de material «{nombre_b}»" + (f" y {len(bancos) - 1} más" if len(bancos) > 1 else ""), que=que_b, estado="espera",
cifra=_gb(sum(b["bytes"] for b in bancos)), calculando=bool(REPLAN.get(raiz)),
boton="" if REPLAN.get(raiz) else "Calcular",
detalle=("Comprobando qué queda… " if REPLAN.get(raiz) else "Hay que calcular el plan para verlo. ") + nota_b,
ir="plan", paso="banco")
# --- 5. carpetas que quedaron vacías --- # --- 5. carpetas que quedaron vacías ---
vac = carpetas_vacias(raiz) vac = carpetas_vacias(raiz)
if vac["n"]: if vac["n"]:
@@ -6017,6 +6307,110 @@ def acciones(raiz):
"automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None} "automatico": auto, "asistente": asistente.ESTADO if asistente.ESTADO.get("raiz", "").lower() == raiz.lower() else None}
def _no_tocar_dentro(con, raiz):
"""« De ellos, 366 GB están dentro de «Material Vía (No tocar)»: van sin marcar.» para la ficha de basura y proxies."""
partes = []
for b in _bancos_de(con, raiz):
if not b["protegido"]:
continue
x = con.execute("SELECT COALESCE(SUM(bytes), 0) b FROM archivos WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1) "
"AND ruta LIKE ? ESCAPE '!'", (raiz, esc_like(con_sep(b["ruta"])) + "%")).fetchone()["b"]
if x:
partes.append(f"{_gb(x)} están dentro de «{b['nombre']}», que dice «No tocar»")
return (" De ellos, " + "; y ".join(partes) + ": van sin marcar, para que decidas tú.") if partes else ""
def _bancos_de(con, raiz):
"""Los bancos de material de una carpeta de cliente: los de su primer nivel. Una copia dentro de otra
carpeta (en Vía Sumapaz, «_Duplicados_para_borrar\\Material Vía (No tocar)») no es el banco."""
base = raiz.rstrip(os.sep).count(os.sep) + 1
out = []
for c in con.execute("SELECT ruta, nombre, n, bytes FROM carpetas WHERE raiz = ? ORDER BY length(ruta)", (raiz,)).fetchall():
if c["ruta"].count(os.sep) != base:
continue
if R.cubeta_carpeta(c["nombre"], DICC["carpetas"])[0] == "BANCO":
out.append({"ruta": c["ruta"], "nombre": c["nombre"], "n": c["n"] or 0, "bytes": c["bytes"] or 0,
"protegido": R.es_no_tocar(c["nombre"])})
return out
def carpeta_detalle(raiz, ruta=None):
"""Lo que hay dentro de cualquier carpeta del cliente, a cualquier profundidad: sus subcarpetas con su peso
(para entrar en «Qué pesa» nivel a nivel), de qué es lo que guarda, de qué cámaras y de qué fechas, y qué
hace la app con ella. Antes «Qué pesa» solo enseñaba el primer nivel, y una carpeta de 3,6 TB era un bloque
gris sin nada dentro: parecía que el escaneo no había entrado."""
raiz = _clave(raiz)
ruta = os.path.normpath(ruta or raiz)
if ruta.lower() != raiz.lower() and not dentro_de(raiz, ruta):
return {"error": "esa carpeta no es de este cliente"}
con = db()
q = lambda sql, *a: [dict(x) for x in con.execute(sql, a).fetchall()] # noqa
pref = esc_like(con_sep(ruta)) + "%"
hijos = q("SELECT ruta, nombre, n, bytes, es_proxy, es_basura FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!' "
"AND ruta NOT LIKE ? ESCAPE '!' ORDER BY bytes DESC", raiz, pref, pref + esc_like(os.sep) + "%")
rel0 = os.path.relpath(ruta, raiz)
en_banco = rel0 != "." and any(R.cubeta_carpeta(s, DICC["carpetas"])[0] == "BANCO" for s in rel0.split(os.sep))
for h in hijos:
cub = "PROXY" if h["es_proxy"] else "BASURA" if h["es_basura"] else \
"BANCO" if en_banco else (R.cubeta_carpeta(h["nombre"], DICC["carpetas"])[0] or "")
h.update(cubeta=cub, color=R.COLOR_CUBETA.get(cub, R.COLOR_CUBETA[""]))
donde = "(dir = ? OR dir LIKE ? ESCAPE '!')"
total = q(f"SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) bytes FROM archivos WHERE raiz = ? AND {donde}", raiz, ruta, pref)[0]
sueltos = q("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) bytes FROM archivos WHERE raiz = ? AND dir = ?", raiz, ruta)[0]
roles = q(f"SELECT rol, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND {donde} GROUP BY rol ORDER BY bytes DESC", raiz, ruta, pref)
nombres_f = {}
for k, _, d in R.FIRMAS:
nombres_f.setdefault(k, d)
fuentes = q(f"SELECT fuente, COUNT(*) n, SUM(bytes) bytes FROM archivos WHERE raiz = ? AND {donde} AND fuente != '' "
f"AND fuente != 'STOCK' GROUP BY fuente ORDER BY bytes DESC", raiz, ruta, pref)
fechas = q(f"SELECT MIN(substr(fecha,1,7)) desde, MAX(substr(fecha,1,7)) hasta, COUNT(DISTINCT substr(fecha,1,7)) meses "
f"FROM archivos WHERE raiz = ? AND {donde} AND rol IN ('camara_raw','camara_video','celular_video','audio_grabado')",
raiz, ruta, pref)[0]
apartable = q(f"SELECT COALESCE(SUM(CASE WHEN es_proxy = 1 THEN bytes END), 0) proxies, "
f"COALESCE(SUM(CASE WHEN es_basura = 1 AND es_proxy = 0 THEN bytes END), 0) basura FROM archivos "
f"WHERE raiz = ? AND {donde}", raiz, ruta, pref)[0]
sub = [x["ruta"] for x in q("SELECT ruta FROM carpetas WHERE raiz = ? AND ruta LIKE ? ESCAPE '!'", raiz, pref)]
niveles = max((s.count(os.sep) - ruta.rstrip(os.sep).count(os.sep) for s in sub), default=0)
grandes = q("SELECT nombre, bytes FROM archivos WHERE raiz = ? AND dir = ? ORDER BY bytes DESC LIMIT 6", raiz, ruta)
con.close()
# qué hace la app con ella: si está dentro de un banco de material, eso manda
trato = {"cubeta": "", "texto": ""}
rel = os.path.relpath(ruta, raiz)
if rel != ".":
segs = rel.split(os.sep)
for s in segs:
if R.cubeta_carpeta(s, DICC["carpetas"])[0] == "BANCO":
trato = {"cubeta": "BANCO", "texto": f"Está en el banco de material «{s}»: los temas se quedan como están y, "
f"dentro, las grabaciones van por fecha de rodaje y cámara."
+ (" Su nombre dice «No tocar»: todo se propone sin marcar." if R.es_no_tocar(s) else "")}
break
else:
cub, mot = R.cubeta_carpeta(segs[-1], DICC["carpetas"])
trato = {"cubeta": cub or "", "texto": (f"La app la reconoce como «{R.CUBETAS.get(cub, cub)}» ({mot})." if cub else
"La app no reconoce el nombre: el plan decide por lo que tiene dentro.")}
plan = PLANES.get(raiz)
en_plan = None
if plan:
rc = os.path.normcase(con_sep(ruta)) if rel != "." else ""
fs = [f for f in plan["filas"] if not rc or os.path.normcase(f["origen"] + os.sep).startswith(rc)
or any(os.path.normcase(a).startswith(rc) for a in (f["archivos"] or [])[:1])]
en_plan = {"filas": len(fs), "marcadas": sum(1 for f in fs if f["marcado"]), "bytes": sum(f["bytes"] for f in fs)}
return {"raiz": raiz, "ruta": ruta, "rel": "" if rel == "." else rel, "nombre": os.path.basename(ruta) or ruta,
"n": total["n"], "bytes": total["bytes"], "sueltos": sueltos, "hijos": hijos, "niveles": niveles,
"carpetas": len(sub), "roles": [dict(x, que=R.ROL_QUE.get(x["rol"], x["rol"])) for x in roles],
"fuentes": [dict(x, que=nombres_f.get(x["fuente"], x["fuente"])) for x in fuentes],
"fechas": fechas, "apartable": apartable, "grandes": grandes, "trato": trato, "en_plan": en_plan}
def _inventario_resumen(raiz):
"""Cuántas carpetas leyó el escaneo y hasta qué profundidad: para que se vea que entró en todas."""
con = db()
rutas = [x[0] for x in con.execute("SELECT ruta FROM carpetas WHERE raiz = ?", (raiz,)).fetchall()]
con.close()
base = raiz.rstrip(os.sep).count(os.sep)
return {"carpetas": len(rutas), "niveles": max((x.count(os.sep) - base for x in rutas), default=0)}
def estado_carpeta(ruta): def estado_carpeta(ruta):
"""El recorrido de una carpeta: qué paso está hecho, cuál toca y qué lo bloquea.""" """El recorrido de una carpeta: qué paso está hecho, cuál toca y qué lo bloquea."""
raiz = _clave(ruta) raiz = _clave(ruta)
@@ -6054,7 +6448,8 @@ def estado_carpeta(ruta):
"diagnostico": diag, "diagnostico": diag,
"sin_nombre": [{"token": t["token"], "ejemplo": t["ejemplo"], "n": t["n"], "bytes": t["bytes"]} for t in sin_nombre[:12]], "sin_nombre": [{"token": t["token"], "ejemplo": t["ejemplo"], "n": t["n"], "bytes": t["bytes"]} for t in sin_nombre[:12]],
"sin_nombre_n": len(sin_nombre), "sin_nombre_n": len(sin_nombre),
"plan": plan_res, "lotes": lotes[:6], "plan": plan_res, "lotes": lotes[:6], "inventario": _inventario_resumen(raiz) if esc else None,
"tipo_cliente": ((CONFIG.get("clientes") or {}).get(r["cliente"]) or {}).get("tipo"),
"convertir": {"camara_bytes": cam_bytes, "raw_bytes": g.get("camara_raw", {}).get("bytes", 0), "trabajos": trabajos, "convertir": {"camara_bytes": cam_bytes, "raw_bytes": g.get("camara_raw", {}).get("bytes", 0), "trabajos": trabajos,
"analisis": ANALISIS.get(raiz), "analizados": analizados["ok"] or 0, "sin_analizar": analizados["sin"] or 0, "analisis": ANALISIS.get(raiz), "analizados": analizados["ok"] or 0, "sin_analizar": analizados["sin"] or 0,
"cola_activa": COLA["activa"]}, "cola_activa": COLA["activa"]},
@@ -6156,6 +6551,9 @@ class Manejador(BaseHTTPRequestHandler):
return self._json(estado_carpeta(q["raiz"])) return self._json(estado_carpeta(q["raiz"]))
if ruta == "/api/mapa": if ruta == "/api/mapa":
return self._json(mapa(q["raiz"])) return self._json(mapa(q["raiz"]))
if ruta == "/api/mapa/carpeta": # «Qué pesa», nivel a nivel
r = carpeta_detalle(q["raiz"], q.get("ruta"))
return self._json(r, 400 if r.get("error") else 200)
if ruta == "/api/actualizacion": if ruta == "/api/actualizacion":
return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta
if ruta == "/api/asistente/pasos": if ruta == "/api/asistente/pasos":
@@ -6343,6 +6741,18 @@ class Manejador(BaseHTTPRequestHandler):
CONFIG["raices"] = d["raices"] CONFIG["raices"] = d["raices"]
guardar_json(CONFIG_PATH, CONFIG) guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True}) return self._json({"ok": True})
if ruta == "/api/raiz/tipo":
# el tipo de trabajo de una carpeta, y el de su cliente con él: así no vuelven a quedar distintos
k = _clave(d.get("raiz") or "")
rc = raiz_cfg(k)
tipo = d.get("tipo")
if not rc or tipo not in PLANTILLAS:
return self._json({"error": "carpeta o tipo de trabajo desconocido"}, 400)
rc["tipo"] = tipo
CONFIG.setdefault("clientes", {}).setdefault(rc["cliente"], {})["tipo"] = tipo
guardar_json(CONFIG_PATH, CONFIG)
olvidar_plan(k)
return self._json({"ok": True, "tipo": tipo})
if ruta == "/api/clientes": if ruta == "/api/clientes":
CONFIG["clientes"] = d["clientes"] CONFIG["clientes"] = d["clientes"]
guardar_json(CONFIG_PATH, CONFIG) guardar_json(CONFIG_PATH, CONFIG)

Ver fichero

@@ -192,6 +192,27 @@ def p_que_hay():
return 'basura %d · proxies %d · %d ramas' % (m['basura']['n'], m['proxies']['n'], len(m['arbol'])) return 'basura %d · proxies %d · %d ramas' % (m['basura']['n'], m['proxies']['n'], len(m['arbol']))
def p_tipo_y_pesa():
# «Qué pesa» entra nivel a nivel
d = get('/api/mapa/carpeta' + q(raiz=RAIZ))
assert d['hijos'] and d['n'] == raiz_estado()['escaneo']['n'], (d['n'], len(d['hijos']))
d2 = get('/api/mapa/carpeta' + q(raiz=RAIZ, ruta=os.path.join(RAIZ, '2026')))
assert d2['rel'] == '2026' and d2['niveles'] >= 2 and d2['roles'], d2
# el tipo de trabajo de la carpeta y el del cliente no cuadran: Inicio lo dice, y elegir uno los iguala
cl = get('/api/estado')['config'].get('clientes') or {}
cl['Cliente Smoke'] = {'tipo': 'proyecto'}
c, r = post('/api/clientes', {'clientes': cl})
assert c == 200, r
a = {x['clave']: x for x in get('/api/carpeta/acciones' + q(raiz=RAIZ))['acciones']}
assert 'tipo' in a and a['tipo']['tipos'] == ['contenido', 'proyecto'], a.get('tipo')
c, r = post('/api/raiz/tipo', {'raiz': RAIZ, 'tipo': 'contenido'})
assert c == 200, r
e = get('/api/estado')
assert e['config']['clientes']['Cliente Smoke']['tipo'] == 'contenido' and raiz_estado()['tipo'] == 'contenido'
assert 'tipo' not in [x['clave'] for x in get('/api/carpeta/acciones' + q(raiz=RAIZ))['acciones']]
return '«Qué pesa» entra hasta %d niveles bajo 2026; el tipo de la carpeta y el del cliente se igualan' % d2['niveles']
def p_nombrar(): def p_nombrar():
v = get('/api/vocabulario' + q(raiz=RAIZ)) v = get('/api/vocabulario' + q(raiz=RAIZ))
toks = {t['token']: t for t in v['tokens']} toks = {t['token']: t for t in v['tokens']}
@@ -590,7 +611,7 @@ def p_quitar():
for nombre, fn in (('Interfaz sin nada externo', p_estatico), ('Registrar carpeta', p_registrar), ('Escanear', p_escanear), for nombre, fn in (('Interfaz sin nada externo', p_estatico), ('Registrar carpeta', p_registrar), ('Escanear', p_escanear),
('Estado y lista de acciones', p_carpeta), ('Qué hay (mapa de peso, discos)', p_que_hay), ('Estado y lista de acciones', p_carpeta), ('Qué hay (mapa de peso, discos)', p_que_hay),
('Por nombrar (vocabulario, diccionario)', p_nombrar), ('Calcular el plan', p_plan), ('Qué pesa por dentro y tipo de trabajo', p_tipo_y_pesa), ('Por nombrar (vocabulario, diccionario)', p_nombrar), ('Calcular el plan', p_plan),
('Aplicar el plan', p_aplicar), ('Deshacer', p_deshacer), ('Volver a aplicar', p_reaplicar), ('Aplicar el plan', p_aplicar), ('Deshacer', p_deshacer), ('Volver a aplicar', p_reaplicar),
('Historial y papelera', p_historial), ('Repetidos', p_repetidos), ('Stock viejo', p_stock), ('Historial y papelera', p_historial), ('Repetidos', p_repetidos), ('Stock viejo', p_stock),
('Carpetas vacías', p_vacias), ('Mapa interactivo', p_mapa), ('Colores en Windows', p_colores), ('Carpetas vacías', p_vacias), ('Mapa interactivo', p_mapa), ('Colores en Windows', p_colores),

Ver fichero

@@ -337,10 +337,71 @@ class Motor(unittest.TestCase):
filas = [{"cubeta": "GRABACION", "marcado": False, "confianza": "baja"}, filas = [{"cubeta": "GRABACION", "marcado": False, "confianza": "baja"},
{"cubeta": "GRABACION", "marcado": True, "confianza": "media"}, {"cubeta": "GRABACION", "marcado": True, "confianza": "media"},
{"cubeta": "BASURA", "marcado": True, "confianza": "alta"}, {"cubeta": "BASURA", "marcado": True, "confianza": "alta"},
{"cubeta": "PROXY", "marcado": False, "confianza": "baja"}, # sin su original, o dentro de «No tocar»
{"cubeta": "EXPORTADOS", "marcado": False, "confianza": "baja"}] {"cubeta": "EXPORTADOS", "marcado": False, "confianza": "baja"}]
elegidas = asistente.filas_elegidas(filas, {"carpetas": "seguras"}) elegidas = asistente.filas_elegidas(filas, {"carpetas": "seguras"})
self.assertEqual([(f["cubeta"], f["marcado"]) for f in elegidas], [("GRABACION", True), ("BASURA", True)]) self.assertEqual([(f["cubeta"], f["marcado"]) for f in elegidas], [("GRABACION", True), ("BASURA", True)])
def test_banco_de_material(self):
"""Vía Sumapaz: 3,6 TB en «Material Vía (No tocar)», por temas, que el plan dejaba fuera por la palabra
«material». Los temas se quedan; dentro, las grabaciones por fecha y cámara; todo sin marcar."""
raiz = cliente("Banco")
b = "Material Vía (No tocar)/"
mk(raiz, b + "Lugares/Peajes/Drone/DJI_20240312101500_0001_D.MP4", 2024, 3, 12)
mk(raiz, b + "Lugares/Peajes/URSA/A001_03121015_C001.mp4", 2024, 3, 12)
mk(raiz, b + "Lugares/Peajes/URSA/Proxy/A001_03121015_C001.mov", 2024, 3, 12) # proxy con su original
mk(raiz, b + "Lugares/Peajes/Final peajes.mp4", 2024, 4, 2) # montado: no se toca
mk(raiz, b + "Material Importante/Social/Mercados/C0001.MP4", 2025, 5, 2)
mk(raiz, b + "Material Importante/UF9/Drone/DJI_0001.MP4", 2009, 1, 19) # reloj sin poner
mk(raiz, "2026/03- Marzo/Grabacion/C0010.MP4", 2026, 3, 12)
mk(raiz, "2026/03- Marzo/Grabacion/Proxy/C0010.MOV", 2026, 3, 12) # proxy con original
mk(raiz, "2026/04- Abril/01- Viejo/Proxy/C0099.MOV", 2026, 4, 2) # el único que queda
dicc = O.DICC["carpetas"]
self.assertEqual(O.R.cubeta_carpeta("Material Vía (No tocar)", dicc)[0], "BANCO")
self.assertIsNone(O.R.cubeta_carpeta("Material juan", dicc)[0], "«material» suelto ya no ignora la carpeta")
self.assertEqual(O.R.cubeta_carpeta("Material", dicc)[0], "IGNORAR")
antes = nombres(raiz)
p = plan(raiz)
banco = [f for f in p["filas"] if f["paso"] == "banco"]
destinos = {f["destino_rel"] for f in banco}
B = "Material Vía (No tocar)"
self.assertIn(rel(B, "Lugares", "Peajes", "2024-03-12", "OSMO"), destinos)
self.assertIn(rel(B, "Lugares", "Peajes", "2024-03-12", "BLACKMAGIC_MP4"), destinos)
self.assertIn(rel(B, "Material Importante", "Social", "Mercados", "2025-05-02", "SONY"), destinos)
self.assertFalse([f for f in banco if f["marcado"]], "en «No tocar» nada va marcado")
self.assertFalse([f for f in p["filas"] if any("Final peajes" in a for a in (f["archivos"] or []))], "movió lo montado")
reloj = [f for f in banco if "reloj sin poner" in f["motivo"]]
self.assertTrue(reloj and reloj[0]["confianza"] == "baja", [f["motivo"] for f in banco])
prox = [f for f in p["filas"] if f["cubeta"] == "PROXY"]
dentro = [f for f in prox if "Peajes" in f["origen_rel"]]
self.assertTrue(dentro and not dentro[0]["marcado"] and "No tocar" in dentro[0]["motivo"], dentro)
sin = [f for f in prox if "01- Viejo" in f["origen_rel"]]
self.assertTrue(sin and not sin[0]["marcado"] and "OJO" in sin[0]["motivo"], sin)
con = [f for f in prox if "03- Marzo" in f["origen_rel"]]
self.assertTrue(con and con[0]["marcado"], con)
# «hacer de una vez» no toca nada de lo que va sin marcar
self.assertFalse([f for f in asistente.filas_elegidas(p["filas"], {"carpetas": "seguras"}) if not f["marcado"]])
dentro_antes = [x for x in arbol(raiz) if x.startswith(B)]
aplicar(raiz, p)
self.assertEqual([x for x in arbol(raiz) if x.startswith(B)], dentro_antes, "tocó la carpeta «No tocar» sin que nadie lo marcara")
self.assertTrue(os.path.isfile(os.path.join(raiz, "2026", "04- Abril", "01- Viejo", "Proxy", "C0099.MOV")), "apartó el proxy sin original")
# si alguien lo marca, queda donde dice
p2 = O.Plan(O.raiz_cfg(raiz)).correr()
for f in p2["filas"]:
f["marcado"] = f["paso"] == "banco"
aplicar(raiz, p2)
self.assertEqual(nombres(raiz), antes, "se perdió o se duplicó algún archivo")
for esperado in (rel(B, "Lugares", "Peajes", "2024-03-12", "OSMO", "DJI_20240312101500_0001_D.MP4"),
rel(B, "Lugares", "Peajes", "2024-03-12", "BLACKMAGIC_MP4", "A001_03121015_C001.mp4"),
rel(B, "Lugares", "Peajes", "Final peajes.mp4"),
rel(B, "Material Importante", "Social", "Mercados", "2025-05-02", "SONY", "C0001.MP4")):
self.assertTrue(os.path.isfile(os.path.join(raiz, esperado)), "falta " + esperado + "\n" + "\n".join(arbol(raiz)))
# y «Qué pesa» entra hasta el fondo y dice cómo la trata la app
d = O.carpeta_detalle(raiz, os.path.join(raiz, B))
self.assertEqual(d["trato"]["cubeta"], "BANCO")
self.assertTrue(d["niveles"] >= 3 and "Lugares" in [h["nombre"] for h in d["hijos"]], d)
self.assertIn("banco", [a["clave"] for a in O.acciones(raiz)["acciones"]])
def test_el_convertidor_no_repite(self): def test_el_convertidor_no_repite(self):
"""22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»).""" """22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»)."""
raiz = cliente("Convertidos") raiz = cliente("Convertidos")

Ver fichero

@@ -451,6 +451,7 @@ CUBETAS = {
"CELULAR": "Material de celular", "CELULAR": "Material de celular",
"PROXY": "Proxies (se pueden regenerar)", "PROXY": "Proxies (se pueden regenerar)",
"BASURA": "Basura de sistema / cachés", "BASURA": "Basura de sistema / cachés",
"BANCO": "Banco de material por temas: los temas se quedan; dentro, las grabaciones por fecha y cámara",
"CONTENEDOR": "Carpeta contenedora: mirar adentro", "CONTENEDOR": "Carpeta contenedora: mirar adentro",
"REVISAR": "Revisar a mano", "REVISAR": "Revisar a mano",
"IGNORAR": "Dejar como está", "IGNORAR": "Dejar como está",
@@ -544,6 +545,21 @@ MARCA_SUB = {
_SEMILLA_POR_LARGO = sorted(DICCIONARIO_SEMILLA, key=lambda k: -len(k)) _SEMILLA_POR_LARGO = sorted(DICCIONARIO_SEMILLA, key=lambda k: -len(k))
# «Material» a secas es estructura (la carpeta de material de una campaña o de la raíz de pauta), pero como
# palabra suelta dentro de otro nombre no dice nada. Medido el 14-oct-2026 en Vía Sumapaz: «Material Vía (No
# tocar)», 3,6 TB y el 85 % del disco, y dentro «Material Importante», 2,6 TB, se quedaban fuera del orden
# por llevar esa palabra delante.
SOLO_NOMBRE_COMPLETO = {"material"}
# «(No tocar)», «NO MOVER»: alguien dejó dicho que esa carpeta se respeta. Es un banco de material: sus
# temas no se mueven y lo que la app propone dentro va sin marcar, para que lo decida una persona.
_RX_NO_TOCAR = re.compile(r"(^| )no (tocar|mover|borrar)( |$)")
def es_no_tocar(nombre: str) -> bool:
return bool(_RX_NO_TOCAR.search(norm(nombre)))
def cubeta_carpeta(nombre: str, diccionario: dict): def cubeta_carpeta(nombre: str, diccionario: dict):
"""(cubeta, motivo) de una carpeta por su nombre; None si no se reconoce.""" """(cubeta, motivo) de una carpeta por su nombre; None si no se reconoce."""
if es_basura_dir(nombre): if es_basura_dir(nombre):
@@ -555,6 +571,8 @@ def cubeta_carpeta(nombre: str, diccionario: dict):
n = norm(nombre) n = norm(nombre)
if n in diccionario: if n in diccionario:
return diccionario[n], f"diccionario: «{n}»" return diccionario[n], f"diccionario: «{n}»"
if _RX_NO_TOCAR.search(n):
return "BANCO", "el nombre dice «No tocar»: es un banco de material"
# tokens de varias palabras primero, luego palabras sueltas # tokens de varias palabras primero, luego palabras sueltas
# (CONTENEDOR solo por coincidencia exacta: "Material juan manuel" no es un cajón de sastre) # (CONTENEDOR solo por coincidencia exacta: "Material juan manuel" no es un cajón de sastre)
# #
@@ -568,7 +586,7 @@ def cubeta_carpeta(nombre: str, diccionario: dict):
return diccionario[k], f"diccionario: contiene «{k}»" return diccionario[k], f"diccionario: contiene «{k}»"
palabras = n.split() palabras = n.split()
for k in claves: for k in claves:
if " " not in k and diccionario[k] != "CONTENEDOR" and k in palabras: if " " not in k and diccionario[k] != "CONTENEDOR" and k not in SOLO_NOMBRE_COMPLETO and k in palabras:
return diccionario[k], f"diccionario: palabra «{k}»" return diccionario[k], f"diccionario: palabra «{k}»"
return None, "" return None, ""
@@ -753,6 +771,7 @@ VERSION_REGLAS = 2
# Un color por tipo de material. Se usa en el mapa, en el gráfico de peso y —si se quiere— en el # Un color por tipo de material. Se usa en el mapa, en el gráfico de peso y —si se quiere— en el
# propio Explorador de Windows, poniéndole a cada carpeta un icono de ese color. # propio Explorador de Windows, poniéndole a cada carpeta un icono de ese color.
COLOR_CUBETA = { COLOR_CUBETA = {
"BANCO": "#b07d4f",
"ANIO": "#4f8cff", "GRABACION": "#e0574f", "PIEZA": "#7c5cff", "CAMPANA": "#ff8c42", "ANIO": "#4f8cff", "GRABACION": "#e0574f", "PIEZA": "#7c5cff", "CAMPANA": "#ff8c42",
"EXPORTADOS": "#3ecf8e", "PORTADAS": "#f5b84b", "APOYOS": "#d96fd9", "MARCA": "#39b8c9", "EXPORTADOS": "#3ecf8e", "PORTADAS": "#f5b84b", "APOYOS": "#d96fd9", "MARCA": "#39b8c9",
"PROYECTOS": "#8fa1b8", "MUSICA": "#c9b339", "SFX": "#c9b339", "VOZ": "#c9b339", "PROYECTOS": "#8fa1b8", "MUSICA": "#c9b339", "SFX": "#c9b339", "VOZ": "#c9b339",

Ver fichero

@@ -145,6 +145,19 @@
.tm{position:relative;height:300px;border-radius:6px;overflow:hidden;background:var(--panel2)} .tm{position:relative;height:300px;border-radius:6px;overflow:hidden;background:var(--panel2)}
.tm div{position:absolute;overflow:hidden;padding:4px 6px;font-size:11px;border:1px solid rgba(255,255,255,.6);color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5);cursor:default} .tm div{position:absolute;overflow:hidden;padding:4px 6px;font-size:11px;border:1px solid rgba(255,255,255,.6);color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5);cursor:default}
.tm div b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tm div b{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tm div.pick{cursor:pointer}.tm div.pick:hover{filter:brightness(1.15)}
.tm .tmvacio{position:static;color:var(--mut);text-shadow:none;border:0;padding:14px;font-size:12.5px}
.tmrow{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(250px,1fr);gap:18px;align-items:start}
.tmrow .tm{height:380px}
@media(max-width:1000px){.tmrow{grid-template-columns:1fr}}
.migas{display:flex;flex-wrap:wrap;gap:4px 6px;align-items:center;margin:0 0 8px;font-size:12.5px}
.migas .lnk{padding:0}
.tmdet{font-size:12.5px}
.tmdet h4{margin:0 0 2px;font-size:14px;word-break:break-word}
.tmdet .tit{margin:10px 0 3px;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut)}
.tmdet .ln{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:8px;padding:1px 0}
.tmdet .trato{margin:8px 0;padding:8px 10px;border-radius:8px;background:var(--panel2)}
.tmdet .acts{margin-top:10px;display:flex;gap:14px}
.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px} .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.toolbar label{color:var(--mut);font-size:12px;display:flex;align-items:center;gap:6px} .toolbar label{color:var(--mut);font-size:12px;display:flex;align-items:center;gap:6px}
.toolbar .sep{flex:1} .toolbar .sep{flex:1}
@@ -545,16 +558,16 @@
<section id="t-revisar"> <section id="t-revisar">
<h2 class="v">Revisar y ordenar</h2> <h2 class="v">Revisar y ordenar</h2>
<p class="ayuda">Todo se mueve <b>dentro de la misma carpeta</b>, y lo único que se borra son las carpetas que se quedan vacías, si las marcas tú. <span class="q" title="Más">?</span><span class="mas">Basura y proxies van a <span class="path">_Para borrar</span>. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.</span></p> <p class="ayuda">Todo se mueve <b>dentro de la misma carpeta</b>, y lo único que se borra son las carpetas que se quedan vacías, si las marcas tú. <span class="q" title="Más">?</span><span class="mas">Basura y proxies van a <span class="path">_Para borrar</span>. Los nombres de archivo no cambian, así Resolve y Premiere pueden re-enlazar. Cada aplicación es un cambio que se deshace desde Historial. Las carpetas que la app no reconoce salen en el plan como «dudoso» y no se marcan: nómbralas en «Por nombrar» y se ordenan solas, en cualquier disco.</span></p>
<div id="mapaCargando" class="hint" style="display:none"><span class="spin"></span> Leyendo el inventario de la carpeta… En una carpeta grande tarda unos segundos.</div>
<div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div> <div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div>
<div id="revisarLleno" style="display:none"> <div id="revisarLleno" style="display:none">
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a></div> <div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a></div>
<div class="sub on" id="s-hay"> <div class="sub on" id="s-hay">
<div class="cards" id="cards"></div> <div class="cards" id="cards"></div>
<div class="row"> <div class="box" style="margin-bottom:18px"><h3>Qué pesa · toca una carpeta para entrar en ella</h3><div class="migas" id="tmMigas"></div>
<div class="box"><h3>Qué pesa (primer nivel)</h3><div class="tm" id="treemap"></div><div class="legend" id="legend" style="margin-top:8px"></div></div> <div class="tmrow"><div><div class="tm" id="treemap"></div><div class="legend" id="legend" style="margin-top:8px"></div></div><div class="tmdet" id="tmDetalle"></div></div></div>
<div class="box"><h3>Avisos</h3><div id="alertas"></div></div> <div class="box" style="margin-bottom:18px"><h3>Avisos</h3><div id="alertas"></div></div>
</div>
<details class="mas"><summary>Más detalle: cámaras, meses, tipos de archivo, repetidos, dónde vive cada cliente</summary> <details class="mas"><summary>Más detalle: cámaras, meses, tipos de archivo, repetidos, dónde vive cada cliente</summary>
<div class="row" style="margin-top:12px"><div class="box"><h3>Por fuente (de qué cámara viene)</h3><div id="fuentes"></div></div><div class="box"><h3>Material de cámara por mes</h3><div id="meses"></div></div></div> <div class="row" style="margin-top:12px"><div class="box"><h3>Por fuente (de qué cámara viene)</h3><div id="fuentes"></div></div><div class="box"><h3>Material de cámara por mes</h3><div id="meses"></div></div></div>
<div class="row"><div class="box"><h3>Por tipo de archivo</h3><div id="exts"></div></div><div class="box"><h3>Repetidos (mismo nombre y peso, el mismo clip en MP4 y MOV, o con dos calidades)</h3><div id="dups"></div><div class="toolbar" style="margin-top:10px"><button id="btnComparar">Comparar y ver todos</button><button class="p" id="btnJuntar" disabled>Juntar en Duplicados</button><span class="mut" id="dupsResumen"></span></div></div></div> <div class="row"><div class="box"><h3>Por tipo de archivo</h3><div id="exts"></div></div><div class="box"><h3>Repetidos (mismo nombre y peso, el mismo clip en MP4 y MOV, o con dos calidades)</h3><div id="dups"></div><div class="toolbar" style="margin-top:10px"><button id="btnComparar">Comparar y ver todos</button><button class="p" id="btnJuntar" disabled>Juntar en Duplicados</button><span class="mut" id="dupsResumen"></span></div></div></div>
@@ -735,6 +748,8 @@
<h3>Revisar y ordenar</h3> <h3>Revisar y ordenar</h3>
<dl> <dl>
<dt>Qué pesa</dt><dd>Toca cualquier bloque para entrar en esa carpeta, nivel a nivel, hasta el fondo. Al lado dice qué guarda, de qué cámaras o grabadoras, de qué fechas, cuántos proxies y basura se pueden apartar, y qué hace la app con ella. <i>Cómo: sale del inventario del escaneo, que entra en todas las carpetas; en Inicio se ve cuántas leyó y hasta qué profundidad.</i></dd>
<dt>Banco de material</dt><dd>Una carpeta de material ordenada por temas (Lugares, Social, Ambiental…). Los temas se quedan como están; dentro de cada uno, las grabaciones van por fecha de rodaje y por cámara: «Lugares\Peajes\Drone» pasa a «Lugares\Peajes\2024-03-12\DJI». Una carpeta cuyo nombre diga «No tocar» se trata así, y todo lo que la app propone dentro va sin marcar: lo marcas tú en «Qué va a pasar», donde el banco es un paso propio. Otra carpeta se puede marcar como banco en «Por nombrar».</dd>
<dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd> <dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd>
<dt>Por nombrar</dt><dd>Las carpetas que no sabe qué son y que no moverá hasta que se lo digas. Propone qué es cada una y tú lo confirmas. No pregunta por las carpetas de la propia app, ni por lo de dentro de las piezas, ni por lo que ya ordena solo. <i>Cómo: mira la forma (si trae sus exportados, su portada y su música es una pieza; si guarda meses dentro, un cajón) y lo que tiene dentro contando archivos, no peso. Lo confirmado se guarda en un diccionario y vale para las carpetas que se llamen exactamente igual, en cualquier disco.</i></dd> <dt>Por nombrar</dt><dd>Las carpetas que no sabe qué son y que no moverá hasta que se lo digas. Propone qué es cada una y tú lo confirmas. No pregunta por las carpetas de la propia app, ni por lo de dentro de las piezas, ni por lo que ya ordena solo. <i>Cómo: mira la forma (si trae sus exportados, su portada y su música es una pieza; si guarda meses dentro, un cajón) y lo que tiene dentro contando archivos, no peso. Lo confirmado se guarda en un diccionario y vale para las carpetas que se llamen exactamente igual, en cualquier disco.</i></dd>
<dt>Qué va a pasar</dt><dd>El plan: qué haría la app en la carpeta, sin mover nada todavía. Lo explica con una carpeta de ejemplo, que se ve pasar de donde está a donde va, y debajo lista lo demás. Son tres pasos (apartar basura y proxies, ordenar por año y mes, ordenar lo demás) y un solo clic aplica todo lo marcado, en orden: no hay que ir uno por uno. Lo que no va solo queda aparte y contado: los cambios cosméticos (solo el nombre, «01 - Celular» → «01- Celular») y los dudosos. Mientras aplica, una barra dice por dónde va, archivo a archivo, y al acabar, qué quedó sin hacer. <i>Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada cambio va como seguro, probable o dudoso, y solo los dos primeros se marcan solos.</i></dd> <dt>Qué va a pasar</dt><dd>El plan: qué haría la app en la carpeta, sin mover nada todavía. Lo explica con una carpeta de ejemplo, que se ve pasar de donde está a donde va, y debajo lista lo demás. Son tres pasos (apartar basura y proxies, ordenar por año y mes, ordenar lo demás) y un solo clic aplica todo lo marcado, en orden: no hay que ir uno por uno. Lo que no va solo queda aparte y contado: los cambios cosméticos (solo el nombre, «01 - Celular» → «01- Celular») y los dudosos. Mientras aplica, una barra dice por dónde va, archivo a archivo, y al acabar, qué quedó sin hacer. <i>Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada cambio va como seguro, probable o dudoso, y solo los dos primeros se marcan solos.</i></dd>
@@ -851,8 +866,8 @@ const fechaCorta = ts => ts ? new Date(ts*1000).toLocaleDateString('es-CO', {day
const hora = ts => ts ? new Date(ts*1000).toLocaleTimeString('es-CO', {hour:'2-digit', minute:'2-digit'}) : ''; const hora = ts => ts ? new Date(ts*1000).toLocaleTimeString('es-CO', {hour:'2-digit', minute:'2-digit'}) : '';
const num = n => (n || 0).toLocaleString('es-CO'); const num = n => (n || 0).toLocaleString('es-CO');
const dur = s => { s = Math.round(s||0); return s >= 3600 ? `${Math.floor(s/3600)}h ${Math.floor(s%3600/60)}m` : `${Math.floor(s/60)}m ${s%60}s`; }; const dur = s => { s = Math.round(s||0); return s >= 3600 ? `${Math.floor(s/3600)}h ${Math.floor(s%3600/60)}m` : `${Math.floor(s/60)}m ${s%60}s`; };
const COLORES = {ANIO:'#4f8cff',GRABACION:'#e0574f',PIEZA:'#7c5cff',CAMPANA:'#ff8c42',EXPORTADOS:'#3ecf8e',PORTADAS:'#f5b84b',APOYOS:'#d96fd9',MARCA:'#39b8c9',PROYECTOS:'#8fa1b8',MUSICA:'#c9b339',SFX:'#c9b339',VOZ:'#c9b339',IA:'#a78bfa',STOCK:'#5aa8e0',CONTENEDOR:'#6b7280',REVISAR:'#ff6b6b',PROXY:'#555c6b',BASURA:'#3a3f4a',APARTADO:'#9aa3ad','':'#4b5563'}; const COLORES = {BANCO:'#b07d4f',ANIO:'#4f8cff',GRABACION:'#e0574f',PIEZA:'#7c5cff',CAMPANA:'#ff8c42',EXPORTADOS:'#3ecf8e',PORTADAS:'#f5b84b',APOYOS:'#d96fd9',MARCA:'#39b8c9',PROYECTOS:'#8fa1b8',MUSICA:'#c9b339',SFX:'#c9b339',VOZ:'#c9b339',IA:'#a78bfa',STOCK:'#5aa8e0',CONTENEDOR:'#6b7280',REVISAR:'#ff6b6b',PROXY:'#555c6b',BASURA:'#3a3f4a',APARTADO:'#9aa3ad','':'#4b5563'};
const NOMBRE_CUBETA = {ANIO:'año',GRABACION:'grabación',PIEZA:'pieza',CAMPANA:'campaña',EXPORTADOS:'exportados',PORTADAS:'portadas',APOYOS:'apoyos',MARCA:'marca',PROYECTOS:'proyectos',MUSICA:'música',SFX:'sonidos',VOZ:'voz',IA:'IA',STOCK:'stock',CONTENEDOR:'contenedor',REVISAR:'por revisar',PROXY:'proxies',BASURA:'basura',CELULAR:'celular',FOTOS:'fotos',APARTADO:'apartado por la app'}; const NOMBRE_CUBETA = {BANCO:'banco de material (temas)',DOCUMENTOS:'documentos',GUION:'guion',IGNORAR:'se deja como está',ANIO:'año',GRABACION:'grabación',PIEZA:'pieza',CAMPANA:'campaña',EXPORTADOS:'exportados',PORTADAS:'portadas',APOYOS:'apoyos',MARCA:'marca',PROYECTOS:'proyectos',MUSICA:'música',SFX:'sonidos',VOZ:'voz',IA:'IA',STOCK:'stock',CONTENEDOR:'contenedor',REVISAR:'por revisar',PROXY:'proxies',BASURA:'basura',CELULAR:'celular',FOTOS:'fotos',APARTADO:'apartado por la app'};
const SEGURIDAD = {alta:'seguro', media:'revisar', baja:'dudoso'}; const SEGURIDAD = {alta:'seguro', media:'revisar', baja:'dudoso'};
let ESTADO = null, MAPA = null, PLAN = null, CAND = [], VOC = [], STOCK = [], RESP = null, CARPETA = null; let ESTADO = null, MAPA = null, PLAN = null, CAND = [], VOC = [], STOCK = [], RESP = null, CARPETA = null;
@@ -885,15 +900,23 @@ async function accion(btn, fn){
// se marca lo que pusimos nosotros: si la acción deja OTRA etiqueta (porque recalculó el plan, vació la // 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 // 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. // 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; const antes = btn.innerHTML, marca = '<span class="spin"></span>' + antes, ficha = {};
btn.disabled = true; btn.innerHTML = marca; btn.disabled = true; btn.innerHTML = marca; btn._accion = ficha;
try { await fn(); } try { await fn(); }
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); }
finally { if (btn.innerHTML === marca) { btn.disabled = false; btn.innerHTML = antes; } } finally {
if (btn.innerHTML === marca) { btn.disabled = false; btn.innerHTML = antes; }
// La etiqueta nueva manda, pero si nadie decidió bloquear el botón (con `bloquear`), se vuelve a poder
// pulsar. Medido el 14-oct-2026: tras el primer cálculo «Calcular» se quedaba gris para siempre (su
// etiqueta pasa a «Calcular de nuevo»), y al cambiar de carpeta «Qué va a pasar» ya no calculaba nada;
// lo mismo «Reanudar» la cola después de «Detener», y «Volver a pintar».
else if (btn._accion === ficha) btn.disabled = false;
if (btn._accion === ficha) btn._accion = null;
}
} }
// qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí // 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) || '')); const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || ''));
function bloquear(id, motivo){ const b = $('#' + id); if (!b) return; b.disabled = !!motivo; const p = $('#' + id + 'PorQue'); if (p) p.textContent = motivo && motivo !== 'x' ? motivo : ''; } function bloquear(id, motivo){ const b = $('#' + id); if (!b) return; b._accion = null; b.disabled = !!motivo; const p = $('#' + id + 'PorQue'); if (p) p.textContent = motivo && motivo !== 'x' ? motivo : ''; }
const seguro = fn => async (...a) => { try { await fn(...a); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); } }; const seguro = fn => async (...a) => { try { await fn(...a); } catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); } };
$$('.ayuda .q').forEach(q => q.onclick = () => q.closest('.ayuda').classList.toggle('abierta')); $$('.ayuda .q').forEach(q => q.onclick = () => q.closest('.ayuda').classList.toggle('abierta'));
@@ -978,7 +1001,7 @@ async function sondear(){ if (SONDEO) return; SONDEO = cargarEstado().catch(e =>
setInterval(sondear, 2500); setInterval(sondear, 2500);
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; VOC = []; 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(){ function limpiarTablas(){
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; PLAN_ABIERTOS = new Set(); $('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; $('#planLista').innerHTML = ''; $('#planEjemplo').innerHTML = ''; $('#planAvance').innerHTML = ''; PLAN_ABIERTOS = new Set(); TM_RUTA = null;
$('#planIntro').innerHTML = 'Pulsa <b>Calcular</b> para ver, paso a paso, qué haría la app en esta carpeta.'; estadoPlan(); $('#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'; $('#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'); $('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero');
@@ -1165,7 +1188,10 @@ async function cargarCarpeta(){
$('#inicioLleno').style.display = raiz ? '' : 'none'; $('#inicioLleno').style.display = raiz ? '' : 'none';
if (!raiz) { pintarPortada(); return; } if (!raiz) { pintarPortada(); return; }
const r = cfgRaiz(); const r = cfgRaiz();
// en una carpeta grande la primera lectura tarda: mejor decirlo que dejar la de antes, o nada
if ($('#acciones').dataset.raiz !== raiz) { $('#acciones').dataset.raiz = ''; $('#siguiente').innerHTML = ''; $('#acciones').innerHTML = '<div class="cabsec">Leyendo la carpeta</div><div class="mut"><span class="spin"></span> En una carpeta grande tarda unos segundos.</div>'; }
CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raiz)); CARPETA = await api('/api/carpeta/estado?raiz=' + encodeURIComponent(raiz));
if (raizActual() !== raiz) return;
$('#cabTitulo').textContent = r.cliente; $('#cabTitulo').textContent = r.cliente;
const e = CARPETA.escaneo; const e = CARPETA.escaneo;
$('#cabSub').innerHTML = `<span class="path">${esc(r.ruta)}</span> · ${esc(ESTADO.tipos[r.tipo] || r.tipo)}${e ? ` · ${num(e.n)} archivos · ${gb(e.bytes)} · escaneada ${fecha(e.ts)}` : ' · sin escanear'}`; $('#cabSub').innerHTML = `<span class="path">${esc(r.ruta)}</span> · ${esc(ESTADO.tipos[r.tipo] || r.tipo)}${e ? ` · ${num(e.n)} archivos · ${gb(e.bytes)} · escaneada ${fecha(e.ts)}` : ' · sin escanear'}`;
@@ -1177,6 +1203,10 @@ async function cargarCarpeta(){
$('#pesoBarra').innerHTML = d.grupos.map(g => `<i style="width:${Math.max(g.pct, 0.4)}%;background:${COLOR[g.clave] || '#ccc'}" title="${esc(g.titulo)} · ${gb(g.bytes)} · ${num(g.n)} archivos"></i>`).join(''); $('#pesoBarra').innerHTML = d.grupos.map(g => `<i style="width:${Math.max(g.pct, 0.4)}%;background:${COLOR[g.clave] || '#ccc'}" title="${esc(g.titulo)} · ${gb(g.bytes)} · ${num(g.n)} archivos"></i>`).join('');
$('#pesoLinea').innerHTML = d.grupos.slice(0, 5).map(g => `<b>${gb(g.bytes)}</b> ${esc(NOMBRE_CORTO[g.clave] || g.titulo)}`).join(' · '); $('#pesoLinea').innerHTML = d.grupos.slice(0, 5).map(g => `<b>${gb(g.bytes)}</b> ${esc(NOMBRE_CORTO[g.clave] || g.titulo)}`).join(' · ');
$('#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>` : ''); $('#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 inv = CARPETA.inventario;
// que se vea que el escaneo entró en todas: cuántas carpetas y hasta qué profundidad
if (inv && e) $('#inicioDiag').insertAdjacentHTML('afterbegin', `<div class="mut" style="margin-bottom:4px">Escaneo completo: ${num(e.n)} archivos en ${num(inv.carpetas)} carpetas, hasta ${inv.niveles} nivel${inv.niveles !== 1 ? 'es' : ''} de profundidad. <button class="lnk" id="verDentro" style="padding:0">Ver qué hay dentro</button></div>`);
const vd = $('#verDentro'); if (vd) vd.onclick = () => { irA('revisar'); subRev('hay'); };
} }
const lotes = CARPETA.lotes || []; 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> · ${cuentaLote(l, true)}${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-') || l.lote.startsWith('tanda-')) ? '' : `<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').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> · ${cuentaLote(l, true)}${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-') || l.lote.startsWith('tanda-')) ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
@@ -1186,7 +1216,19 @@ async function cargarCarpeta(){
// Una sola lista, en orden, con la cifra de cada cosa y un botón. Sustituye a repartir el trabajo // Una sola lista, en orden, con la cifra de cada cosa y un botón. Sustituye a repartir el trabajo
// entre pestañas dentro de pestañas: el usuario ve de un vistazo qué falta y cuánto pesa. // entre pestañas dentro de pestañas: el usuario ve de un vistazo qué falta y cuánto pesa.
const IR_ACCION = { const IR_ACCION = {
plan: p => { irA('revisar'); if (PLAN && p) { PLAN_PASO = p; PLAN_EJ = 0; pintarPlan(); } subRev('plan'); if (!PLAN) $('#btnPlan').click(); }, plan: p => { if (p) PLAN_PASO = p; irA('revisar'); if (PLAN && p) { PLAN_EJ = 0; pintarPlan(); } subRev('plan'); if (!PLAN) $('#btnPlan').click(); },
tipo: (_, a) => {
const [actual, delCliente] = a.tipos || [];
modal('Cómo se ordena esta carpeta', `<p>${esc(a.que)}</p><div class="tipos" id="mTipo">${tarjetasTipo(delCliente || actual)}</div><p class="mut" style="font-size:12.5px">El plan se vuelve a calcular con el que elijas. No se mueve nada hasta que apliques.</p>`,
seguro(async () => {
const t = (document.querySelector('#mTipo input:checked') || {}).value; if (!t) return;
await api('/api/raiz/tipo', {raiz: raizActual(), tipo: t});
PLAN = null; MAPAE = null;
aviso(`Listo: la carpeta y el cliente se ordenan como «${(ESTADO.tipos[t] || t).split(' · ')[0]}».`, 'ok');
await cargarEstado(); cargarVista();
}));
$('#modalSi').textContent = 'Usar este';
},
nombrar: () => { irA('revisar'); subRev('nombrar'); }, nombrar: () => { irA('revisar'); subRev('nombrar'); },
stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); }, stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); },
vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); }, vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); },
@@ -1210,6 +1252,7 @@ async function pintarAcciones(){
const d = await api('/api/carpeta/acciones?raiz=' + encodeURIComponent(raiz)); const d = await api('/api/carpeta/acciones?raiz=' + encodeURIComponent(raiz));
if (raizActual() !== raiz) return; // cambió de carpeta mientras llegaba if (raizActual() !== raiz) return; // cambió de carpeta mientras llegaba
ACCIONES = d.acciones || []; ACCIONES = d.acciones || [];
$('#acciones').dataset.raiz = raiz; // de qué carpeta es lo que se ve: así se sabe cuándo decir «leyendo»
const quedan = ACCIONES.filter(a => a.estado === 'toca').length; const quedan = ACCIONES.filter(a => a.estado === 'toca').length;
const auto = d.automatico || [], asis = d.asistente; const auto = d.automatico || [], asis = d.asistente;
const corriendo = asis && asis.activo; const corriendo = asis && asis.activo;
@@ -1218,7 +1261,7 @@ async function pintarAcciones(){
: `<div class="cabsec">${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día' + (d.ultimo ? ` · último cambio ${esc(fechaCorta(d.ultimo.ts))}` : '')}${auto.length > 1 ? '<span class="sep"></span><button class="p" id="btnHacerTodo" style="text-transform:none;letter-spacing:0">Hacer las ' + auto.length + ' de una vez</button>' : ''}</div>`; : `<div class="cabsec">${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día' + (d.ultimo ? ` · último cambio ${esc(fechaCorta(d.ultimo.ts))}` : '')}${auto.length > 1 ? '<span class="sep"></span><button class="p" id="btnHacerTodo" style="text-transform:none;letter-spacing:0">Hacer las ' + auto.length + ' de una vez</button>' : ''}</div>`;
$('#acciones').innerHTML = cab + $('#acciones').innerHTML = cab +
ACCIONES.map((a, i) => `<div class="ac ${a.estado}"><span class="ico">${a.calculando ? '<span class="spin"></span>' : a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}</span><div class="tx"><b>${esc(a.titulo)}</b><div class="q">${esc(a.que)}</div>${a.detalle ? `<div class="d">${esc(a.detalle)}</div>` : ''}</div><div class="der">${a.cifra ? `<span class="cifra">${esc(a.cifra)}</span>` : ''}${a.boton ? `<button class="${a.estado === 'espera' ? 'lnk' : 'p'}" data-a="${i}">${esc(a.boton)}</button>` : ''}</div></div>`).join(''); ACCIONES.map((a, i) => `<div class="ac ${a.estado}"><span class="ico">${a.calculando ? '<span class="spin"></span>' : a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}</span><div class="tx"><b>${esc(a.titulo)}</b><div class="q">${esc(a.que)}</div>${a.detalle ? `<div class="d">${esc(a.detalle)}</div>` : ''}</div><div class="der">${a.cifra ? `<span class="cifra">${esc(a.cifra)}</span>` : ''}${a.boton ? `<button class="${a.estado === 'espera' ? 'lnk' : 'p'}" data-a="${i}">${esc(a.boton)}</button>` : ''}</div></div>`).join('');
$$('#acciones [data-a]').forEach(b => b.onclick = () => { const a = ACCIONES[+b.dataset.a]; (IR_ACCION[a.ir] || (() => {}))(a.paso); }); $$('#acciones [data-a]').forEach(b => b.onclick = () => { const a = ACCIONES[+b.dataset.a]; (IR_ACCION[a.ir] || (() => {}))(a.paso, a); });
// se acaba de mover algo y el programa está comprobando qué queda: se vuelve a preguntar en un momento // se acaba de mover algo y el programa está comprobando qué queda: se vuelve a preguntar en un momento
if (d.calculando) setTimeout(() => { if (raizActual() === raiz && vistaActual() === 'carpeta') cargarCarpeta().catch(() => {}); }, 1500); if (d.calculando) setTimeout(() => { if (raizActual() === raiz && vistaActual() === 'carpeta') cargarCarpeta().catch(() => {}); }, 1500);
const bt = $('#btnHacerTodo'); const bt = $('#btnHacerTodo');
@@ -1316,7 +1359,12 @@ async function deshacer(lote){
// ---------------- revisar: qué hay ---------------- // ---------------- revisar: qué hay ----------------
async function cargarMapa(){ async function cargarMapa(){
if (!raizActual()) { $('#mapaVacio').style.display = 'block'; $('#revisarLleno').style.display = 'none'; return; } if (!raizActual()) { $('#mapaVacio').style.display = 'block'; $('#revisarLleno').style.display = 'none'; return; }
MAPA = await api('/api/mapa?raiz=' + encodeURIComponent(raizActual())); const raiz = raizActual();
if (!MAPA || MAPA.raiz !== raiz) { $('#mapaVacio').style.display = 'none'; $('#revisarLleno').style.display = 'none'; $('#mapaCargando').style.display = 'block'; }
let m0;
try { m0 = await api('/api/mapa?raiz=' + encodeURIComponent(raiz)); } finally { $('#mapaCargando').style.display = 'none'; }
if (raizActual() !== raiz) return;
MAPA = m0;
$('#mapaVacio').style.display = MAPA.escaneado ? 'none' : 'block'; $('#mapaVacio').style.display = MAPA.escaneado ? 'none' : 'block';
$('#revisarLleno').style.display = MAPA.escaneado ? 'block' : 'none'; $('#revisarLleno').style.display = MAPA.escaneado ? 'block' : 'none';
cargarDiscos().catch(() => {}); cargarDiscos().catch(() => {});
@@ -1329,9 +1377,7 @@ async function cargarMapa(){
[gb(m.duplicados.bytes), `repetidos (${m.duplicados.grupos} grupos)`], [gb(m.proxies.bytes), `proxies (${m.proxies.n})`], [gb(m.duplicados.bytes), `repetidos (${m.duplicados.grupos} grupos)`], [gb(m.proxies.bytes), `proxies (${m.proxies.n})`],
[num(m.basura.n), `archivos basura · ${gb(m.basura.bytes)}`], [m.sueltos_raiz.n, `sueltos en la raíz · ${gb(m.sueltos_raiz.bytes)}`], [num(m.basura.n), `archivos basura · ${gb(m.basura.bytes)}`], [m.sueltos_raiz.n, `sueltos en la raíz · ${gb(m.sueltos_raiz.bytes)}`],
].map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join(''); ].map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join('');
treemap($('#treemap'), m.arbol.map(h => ({nombre:h.nombre, bytes:h.bytes, n:h.n, color: COLORES[h.cubeta] || COLORES[''], hijos:h.hijos, cubeta:h.cubeta}))); verCarpetaTm(TM_RUTA);
const usados = [...new Set(m.arbol.map(h => h.cubeta || ''))];
$('#legend').innerHTML = usados.map(c => `<span><i style="background:${COLORES[c]||COLORES['']}"></i>${NOMBRE_CUBETA[c] || c || 'no reconocida'}</span>`).join('');
const al = []; const al = [];
if (m.basura.n) al.push(`<li><b>${num(m.basura.n)} archivos basura</b> (${gb(m.basura.bytes)}): .DS_Store, Thumbs.db, .tmp, autoguardados de Adobe. Al ordenar van a _Para borrar.</li>`); if (m.basura.n) al.push(`<li><b>${num(m.basura.n)} archivos basura</b> (${gb(m.basura.bytes)}): .DS_Store, Thumbs.db, .tmp, autoguardados de Adobe. Al ordenar van a _Para borrar.</li>`);
if (m.proxies.n) al.push(`<li><b>${gb(m.proxies.bytes)} en proxies</b> (${m.proxies.n}). Se regeneran desde Resolve/Premiere: sobran en el disco.</li>`); if (m.proxies.n) al.push(`<li><b>${gb(m.proxies.bytes)} en proxies</b> (${m.proxies.n}). Se regeneran desde Resolve/Premiere: sobran en el disco.</li>`);
@@ -1366,13 +1412,47 @@ $('#btnJuntar').onclick = () => { if (!DUPS || DUPS.raiz !== raizActual()) retur
await cargarEstado(); cargarVista(); await cargarEstado(); cargarVista();
})); $('#modalSi').textContent = 'Juntar'; }; })); $('#modalSi').textContent = 'Juntar'; };
function treemap(el, items){ // «Qué pesa», nivel a nivel: se entra en cualquier carpeta y al lado dice qué guarda, de qué cámaras, de
// qué fechas y qué hace la app con ella. Antes solo había el primer nivel, y en Vía Sumapaz una carpeta de
// 3,6 TB era un bloque gris sin nada dentro: parecía que el escaneo no había entrado.
let TM_RUTA = null;
const mesCorto = ym => { const [a, m] = (ym || '').split('-'); return m ? `${MESES_UI[+m - 1].slice(0, 3).toLowerCase()} ${a}` : ''; };
const verCarpetaTm = seguro(async ruta => {
const raiz = raizActual(); if (!raiz) return;
const d = await api(`/api/mapa/carpeta?raiz=${encodeURIComponent(raiz)}${ruta ? '&ruta=' + encodeURIComponent(ruta) : ''}`);
if (raizActual() !== raiz) return;
TM_RUTA = d.rel ? d.ruta : null;
const items = d.hijos.map(h => ({nombre: h.nombre, bytes: h.bytes, n: h.n, color: COLORES[h.cubeta] || h.color || COLORES[''], cubeta: h.cubeta, ruta: h.ruta}));
if (d.sueltos.n && d.hijos.length) items.push({nombre: 'archivos sueltos aquí', bytes: d.sueltos.bytes, n: d.sueltos.n, color: '#9aa3ad', cubeta: ''});
treemap($('#treemap'), items, r => verCarpetaTm(r));
if (!d.hijos.length) $('#treemap').innerHTML = `<div class="tmvacio">Aquí ya no hay más carpetas: ${num(d.n)} archivo${d.n !== 1 ? 's' : ''} sueltos, ${gb(d.bytes)}.</div>`;
const usados = [...new Set(items.map(i => i.cubeta || ''))];
$('#legend').innerHTML = usados.map(c => `<span><i style="background:${COLORES[c] || COLORES['']}"></i>${NOMBRE_CUBETA[c] || c || 'no reconocida'}</span>`).join('');
let acum = d.raiz.replace(/[\\/]+$/, '');
const segs = d.rel ? d.rel.split(/[\\/]/) : [];
$('#tmMigas').innerHTML = [`<button class="lnk" data-r="">${esc((cfgRaiz() || {}).cliente || d.raiz)}</button>`]
.concat(segs.map(s => { acum += '\\' + s; return `<button class="lnk" data-r="${esc(acum)}">${esc(s)}</button>`; })).join('<span class="mut">›</span>');
$$('#tmMigas [data-r]').forEach(b => b.onclick = () => verCarpetaTm(b.dataset.r || null));
const fila = (a, b, c) => `<div class="ln"><span>${esc(a)}</span><b>${b}</b><span class="mut">${c}</span></div>`;
$('#tmDetalle').innerHTML = `<h4>${esc(d.rel ? d.nombre : ((cfgRaiz() || {}).cliente || d.nombre))}</h4>
<div><b>${gb(d.bytes)}</b> · ${num(d.n)} archivo${d.n !== 1 ? 's' : ''}${d.carpetas ? ` · ${num(d.carpetas)} carpetas dentro, hasta ${d.niveles} nivel${d.niveles !== 1 ? 'es' : ''} más abajo` : ''}</div>
${d.trato.texto ? `<p class="trato">${esc(d.trato.texto)}</p>` : ''}
${d.en_plan ? `<div class="mut">En el plan: ${d.en_plan.filas ? `${num(d.en_plan.filas)} cambio${d.en_plan.filas !== 1 ? 's' : ''} (${gb(d.en_plan.bytes)}), ${num(d.en_plan.marcadas)} marcado${d.en_plan.marcadas !== 1 ? 's' : ''}` : 'nada que cambiar'}.</div>` : ''}
<div class="tit">Qué guarda</div>${d.roles.slice(0, 6).map(r => fila(r.que, gb(r.bytes), num(r.n))).join('') || '<div class="mut">Nada.</div>'}
${d.fuentes.length ? '<div class="tit">De qué cámara o grabadora</div>' + d.fuentes.slice(0, 6).map(f => fila(f.que, gb(f.bytes), num(f.n))).join('') : ''}
${d.fechas.desde ? `<div class="tit">Cuándo se grabó</div><div>De ${mesCorto(d.fechas.desde)} a ${mesCorto(d.fechas.hasta)} · ${d.fechas.meses} mes${d.fechas.meses !== 1 ? 'es' : ''} distintos</div>` : ''}
${d.apartable.proxies || d.apartable.basura ? `<div class="mut" style="margin-top:6px">Se puede apartar: ${gb(d.apartable.proxies)} en proxies y ${gb(d.apartable.basura)} de basura.</div>` : ''}
<div class="acts"><button class="lnk" id="tmAbrir">Abrir en el Explorador</button>${d.rel ? '<button class="lnk" id="tmSubir">Subir un nivel</button>' : ''}</div>`;
$('#tmAbrir').onclick = seguro(async () => { await api('/api/abrir', {ruta: d.ruta}); });
const sb = $('#tmSubir'); if (sb) sb.onclick = () => { const p = d.ruta.replace(/[\\/][^\\/]+$/, ''); verCarpetaTm(p.toLowerCase() === d.raiz.toLowerCase() ? null : p); };
});
function treemap(el, items, alPulsar){
el.innerHTML = ''; const W = el.clientWidth || 600, H = el.clientHeight || 300; el.innerHTML = ''; const W = el.clientWidth || 600, H = el.clientHeight || 300;
const total = items.reduce((s,i) => s + i.bytes, 0) || 1; const total = items.reduce((s,i) => s + i.bytes, 0) || 1;
const nodos = items.filter(i => i.bytes > 0).sort((a,b) => b.bytes - a.bytes).map(i => ({...i, area: i.bytes/total*W*H})); const nodos = items.filter(i => i.bytes > 0).sort((a,b) => b.bytes - a.bytes).map(i => ({...i, area: i.bytes/total*W*H}));
let x = 0, y = 0, w = W, h = H, fila = [], resto = nodos.slice(); let x = 0, y = 0, w = W, h = H, fila = [], resto = nodos.slice();
const peor = (fila, lado) => { const s = fila.reduce((a,b)=>a+b.area,0); const mx = Math.max(...fila.map(f=>f.area)), mn = Math.min(...fila.map(f=>f.area)); return Math.max(lado*lado*mx/(s*s), s*s/(lado*lado*mn)); }; const peor = (fila, lado) => { const s = fila.reduce((a,b)=>a+b.area,0); const mx = Math.max(...fila.map(f=>f.area)), mn = Math.min(...fila.map(f=>f.area)); return Math.max(lado*lado*mx/(s*s), s*s/(lado*lado*mn)); };
const pinta = (fila, lado, horizontal) => { const s = fila.reduce((a,b)=>a+b.area,0); const grosor = s/lado; let off = 0; fila.forEach(f => { const largo = f.area/grosor; const d = document.createElement('div'); const rx = horizontal ? x+off : x, ry = horizontal ? y : y+off, rw = horizontal ? largo : grosor, rh = horizontal ? grosor : largo; d.style.left = rx+'px'; d.style.top = ry+'px'; d.style.width = rw+'px'; d.style.height = rh+'px'; d.style.background = f.color; d.title = `${f.nombre}\n${gb(f.bytes)} · ${f.n} archivos${f.cubeta ? '\n' + (NOMBRE_CUBETA[f.cubeta] || f.cubeta) : ''}` + (f.hijos && f.hijos.length ? '\n\n' + f.hijos.slice(0,12).map(h => ` ${h.nombre}: ${gb(h.bytes)}`).join('\n') : ''); d.innerHTML = rw > 40 && rh > 18 ? `<b>${esc(f.nombre)}</b>${rh > 34 ? `<span>${gb(f.bytes)}</span>` : ''}` : ''; el.appendChild(d); off += largo; }); if (horizontal) { y += grosor; h -= grosor; } else { x += grosor; w -= grosor; } }; const pinta = (fila, lado, horizontal) => { const s = fila.reduce((a,b)=>a+b.area,0); const grosor = s/lado; let off = 0; fila.forEach(f => { const largo = f.area/grosor; const d = document.createElement('div'); const rx = horizontal ? x+off : x, ry = horizontal ? y : y+off, rw = horizontal ? largo : grosor, rh = horizontal ? grosor : largo; d.style.left = rx+'px'; d.style.top = ry+'px'; d.style.width = rw+'px'; d.style.height = rh+'px'; d.style.background = f.color; d.title = `${f.nombre}\n${gb(f.bytes)} · ${f.n} archivos${f.cubeta ? '\n' + (NOMBRE_CUBETA[f.cubeta] || f.cubeta) : ''}` + (f.hijos && f.hijos.length ? '\n\n' + f.hijos.slice(0,12).map(h => ` ${h.nombre}: ${gb(h.bytes)}`).join('\n') : ''); d.innerHTML = rw > 40 && rh > 18 ? `<b>${esc(f.nombre)}</b>${rh > 34 ? `<span>${gb(f.bytes)}</span>` : ''}` : ''; if (f.ruta && alPulsar) { d.classList.add('pick'); d.onclick = () => alPulsar(f.ruta); } el.appendChild(d); off += largo; }); if (horizontal) { y += grosor; h -= grosor; } else { x += grosor; w -= grosor; } };
while (resto.length) { const horizontal = w >= h; const lado = horizontal ? w : h; const n = resto.shift(); if (!fila.length || peor(fila.concat(n), lado) <= peor(fila, lado)) fila.push(n); else { pinta(fila, lado, !horizontal); fila = [n]; } } while (resto.length) { const horizontal = w >= h; const lado = horizontal ? w : h; const n = resto.shift(); if (!fila.length || peor(fila.concat(n), lado) <= peor(fila, lado)) fila.push(n); else { pinta(fila, lado, !horizontal); fila = [n]; } }
if (fila.length) pinta(fila, w >= h ? w : h, !(w >= h)); if (fila.length) pinta(fila, w >= h ? w : h, !(w >= h));
} }
@@ -1429,7 +1509,10 @@ const PASOS_PLAN = [
{k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema y proxies van a «_Para borrar». Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'}, {k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema y proxies van a «_Para borrar». Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'},
{k: 'fechas', t: 'Ordenar por año y mes', q: 'Grabaciones y piezas, en la carpeta del mes en que se grabaron.', vacio: 'Las fechas ya están en orden.'}, {k: 'fechas', t: 'Ordenar por año y mes', q: 'Grabaciones y piezas, en la carpeta del mes en que se grabaron.', vacio: 'Las fechas ya están en orden.'},
{k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música y marca, cada cosa a su carpeta.', vacio: 'Nada más que ordenar.'}, {k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música y marca, cada cosa a su carpeta.', vacio: 'Nada más que ordenar.'},
{k: 'banco', t: 'Banco de material', q: 'Los temas se quedan; dentro de cada uno, las grabaciones por fecha de rodaje y cámara.', vacio: 'Las grabaciones del banco ya están por fecha y cámara.'},
]; ];
// el banco de material solo sale si la carpeta tiene uno
const pasosVisibles = () => PASOS_PLAN.filter(p => p.k !== 'banco' || filasPaso('banco').length);
const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'}; const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'};
let PLAN_PASO = null, PLAN_EJ = 0, PLAN_VER_OPC = false, PLAN_POR_QUE = false, PLAN_AVISOS = false, ACCIONES = []; let PLAN_PASO = null, PLAN_EJ = 0, PLAN_VER_OPC = false, PLAN_POR_QUE = false, PLAN_AVISOS = false, ACCIONES = [];
const pasoDe = r => r.paso || 'resto'; const pasoDe = r => r.paso || 'resto';
@@ -1450,7 +1533,10 @@ function estadoPlan(){
} }
async function calcularPlan(){ async function calcularPlan(){
$('#planIntro').innerHTML = '<span class="spin"></span> mirando la carpeta…'; $('#planIntro').innerHTML = '<span class="spin"></span> mirando la carpeta…';
PLAN = await api('/api/plan?raiz=' + encodeURIComponent(raizActual())); const raiz = raizActual();
const p = await api('/api/plan?raiz=' + encodeURIComponent(raiz));
if (raizActual() !== raiz) return; // se cambió de carpeta mientras calculaba
PLAN = p;
PLAN_EJ = 0; PLAN_VER_OPC = false; PLAN_EJ = 0; PLAN_VER_OPC = false;
pintarPlan(); pintarPlan();
} }
@@ -1472,8 +1558,9 @@ function pasoPorDefecto(){
} }
// «43 cosméticos y 5 dudosos»: por qué hay cambios sin marcar // «43 cosméticos y 5 dudosos»: por qué hay cambios sin marcar
function resumenOpc(rs){ function resumenOpc(rs){
const cos = rs.filter(r => r.cosmetico).length, dud = rs.filter(r => !r.cosmetico && r.confianza === 'baja').length, otros = rs.length - cos - dud; const nt = rs.filter(r => r.no_tocar).length, cos = rs.filter(r => r.cosmetico && !r.no_tocar).length,
return [cos ? `${cos} cosmético${cos > 1 ? 's' : ''}` : '', dud ? `${dud} dudoso${dud > 1 ? 's' : ''}` : '', otros ? `${otros} que desmarcaste` : ''].filter(Boolean).join(', ').replace(/, ([^,]*)$/, ' y $1'); dud = rs.filter(r => !r.cosmetico && !r.no_tocar && r.confianza === 'baja').length, otros = rs.length - nt - cos - dud;
return [nt ? `${nt} dentro de «No tocar»` : '', cos ? `${cos} cosmético${cos > 1 ? 's' : ''}` : '', dud ? `${dud} dudoso${dud > 1 ? 's' : ''}` : '', otros ? `${otros} que desmarcaste` : ''].filter(Boolean).join(', ').replace(/, ([^,]*)$/, ' y $1');
} }
function pintarPlan(){ function pintarPlan(){
if (!PLAN) { estadoPlan(); return; } if (!PLAN) { estadoPlan(); return; }
@@ -1492,7 +1579,7 @@ function pintarCabPlan(){
$('#planIntro').innerHTML = !tot ? `<b class="grande">La carpeta ya está en orden.</b> No hay nada que apartar ni mover.` $('#planIntro').innerHTML = !tot ? `<b class="grande">La carpeta ya está en orden.</b> No hay nada que apartar ni mover.`
: !marc.length ? `<b class="grande">Lo seguro ya está hecho.</b> Quedan ${sin.length} cambio${sin.length > 1 ? 's' : ''} que la app no hace sola (${resumenOpc(sin)}). Están abajo, por si quieres marcarlos.` : !marc.length ? `<b class="grande">Lo seguro ya está hecho.</b> Quedan ${sin.length} cambio${sin.length > 1 ? 's' : ''} que la app no hace sola (${resumenOpc(sin)}). Están abajo, por si quieres marcarlos.`
: `<b class="grande">La app va a hacer ${marc.length} cambio${marc.length > 1 ? 's' : ''}</b> (${gb(marc.reduce((a, r) => a + r.bytes, 0))}) dentro de <span class="path">${esc(PLAN.raiz)}</span>. Nada se borra, y se deshace desde Historial.${sin.length ? ` Otros ${sin.length} quedan sin marcar (${resumenOpc(sin)}): están abajo, por si los quieres.` : ''}`; : `<b class="grande">La app va a hacer ${marc.length} cambio${marc.length > 1 ? 's' : ''}</b> (${gb(marc.reduce((a, r) => a + r.bytes, 0))}) dentro de <span class="path">${esc(PLAN.raiz)}</span>. Nada se borra, y se deshace desde Historial.${sin.length ? ` Otros ${sin.length} quedan sin marcar (${resumenOpc(sin)}): están abajo, por si los quieres.` : ''}`;
$('#planPasos').innerHTML = PASOS_PLAN.map((p, i) => { $('#planPasos').innerHTML = pasosVisibles().map((p, i) => {
const rs = filasPaso(p.k), m = rs.filter(r => r.marcado), vacio = !rs.length, on = PLAN_PASO === p.k; const rs = filasPaso(p.k), m = rs.filter(r => r.marcado), vacio = !rs.length, on = PLAN_PASO === p.k;
return `<div class="ficha ${vacio ? 'vacio' : ''} ${on ? 'on' : ''}" data-paso="${p.k}" ${vacio ? '' : `tabindex="0" role="button" aria-pressed="${on}"`}><span class="num">${vacio ? '✓' : i + 1}</span><div class="tx"><b>${p.t}</b><div class="q">${vacio ? p.vacio : p.q}</div>${vacio ? '' : `<div class="cifra">${m.length ? `<b>${m.length}</b> cambio${m.length > 1 ? 's' : ''} · ${gb(m.reduce((a, r) => a + r.bytes, 0))}` : '<b>0</b> marcados'}${rs.length > m.length ? ` <span class="mut">· ${rs.length - m.length} sin marcar</span>` : ''}</div>`}</div>${vacio || !m.length ? '' : `<button class="lnk" data-aplicar="${p.k}" title="Aplicar solo lo marcado de este paso">solo este paso</button>`}</div>`; return `<div class="ficha ${vacio ? 'vacio' : ''} ${on ? 'on' : ''}" data-paso="${p.k}" ${vacio ? '' : `tabindex="0" role="button" aria-pressed="${on}"`}><span class="num">${vacio ? '✓' : i + 1}</span><div class="tx"><b>${p.t}</b><div class="q">${vacio ? p.vacio : p.q}</div>${vacio ? '' : `<div class="cifra">${m.length ? `<b>${m.length}</b> cambio${m.length > 1 ? 's' : ''} · ${gb(m.reduce((a, r) => a + r.bytes, 0))}` : '<b>0</b> marcados'}${rs.length > m.length ? ` <span class="mut">· ${rs.length - m.length} sin marcar</span>` : ''}</div>`}</div>${vacio || !m.length ? '' : `<button class="lnk" data-aplicar="${p.k}" title="Aplicar solo lo marcado de este paso">solo este paso</button>`}</div>`;
}).join(''); }).join('');
@@ -1563,12 +1650,13 @@ function pintarLista(){
const peso = {alta: 0, media: 1, baja: 2}, orden = (a, b) => (peso[a.confianza] - peso[b.confianza]) || b.bytes - a.bytes; const peso = {alta: 0, media: 1, baja: 2}, orden = (a, b) => (peso[a.confianza] - peso[b.confianza]) || b.bytes - a.bytes;
const todas = filasPaso(PLAN_PASO).filter(pasa).sort(orden), marc = todas.filter(r => r.marcado), sin = todas.filter(r => !r.marcado), ej = ejemploActual(); const todas = filasPaso(PLAN_PASO).filter(pasa).sort(orden), marc = todas.filter(r => r.marcado), sin = todas.filter(r => !r.marcado), ej = ejemploActual();
const grupos = rs => { const g = {}; rs.forEach(r => (g[r.cubeta] = g[r.cubeta] || []).push(r)); return Object.keys(g).map(c => `<div class="grp">${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}</div>` + g[c].map(r => filaHtml(r, ej)).join('')).join(''); }; const grupos = rs => { const g = {}; rs.forEach(r => (g[r.cubeta] = g[r.cubeta] || []).push(r)); return Object.keys(g).map(c => `<div class="grp">${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}</div>` + g[c].map(r => filaHtml(r, ej)).join('')).join(''); };
const cos = sin.filter(r => r.cosmetico), dud = sin.filter(r => !r.cosmetico); const nt = sin.filter(r => r.no_tocar), cos = sin.filter(r => r.cosmetico && !r.no_tocar), dud = sin.filter(r => !r.cosmetico && !r.no_tocar);
const explica = [cos.length ? `${cos.length === 1 ? 'uno solo cambia' : cos.length + ' solo cambian'} el nombre a la forma «NN- Título» (como «01 - Celular» → «01- Celular»): da igual hacerlo o no` : '', dud.length ? `${dud.length} ${dud.length === 1 ? 'es dudoso' : 'son dudosos'}: la app no está segura y no ${dud.length === 1 ? 'lo' : 'los'} mueve sin que lo marques tú` : ''].filter(Boolean).join('; '); const explica = [nt.length ? `${nt.length === 1 ? 'uno está' : nt.length + ' están'} dentro de una carpeta que dice «No tocar»: no se mueve${nt.length === 1 ? '' : 'n'} hasta que ${nt.length === 1 ? 'lo' : 'los'} marques tú` : '', cos.length ? `${cos.length === 1 ? 'uno solo cambia' : cos.length + ' solo cambian'} el nombre a la forma «NN- Título» (como «01 - Celular» → «01- Celular»): da igual hacerlo o no` : '', dud.length ? `${dud.length} ${dud.length === 1 ? 'es dudoso' : 'son dudosos'}: la app no está segura y no ${dud.length === 1 ? 'lo' : 'los'} mueve sin que lo marques tú` : ''].filter(Boolean).join('; ');
caja.innerHTML = `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`; caja.innerHTML = `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}${PLAN_PASO === 'banco' && sin.length > 1 ? `<button class="lnk" id="btnMarcarBanco">marcar los ${sin.length}</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`;
$('#chkPorQue').onchange = e => { PLAN_POR_QUE = e.target.checked; caja.querySelector('.lista').classList.toggle('porque', PLAN_POR_QUE); }; $('#chkPorQue').onchange = e => { PLAN_POR_QUE = e.target.checked; caja.querySelector('.lista').classList.toggle('porque', PLAN_POR_QUE); };
const bo = $('#btnVerOpc'); if (bo) bo.onclick = () => { PLAN_VER_OPC = !PLAN_VER_OPC; pintarLista(); }; const bo = $('#btnVerOpc'); if (bo) bo.onclick = () => { PLAN_VER_OPC = !PLAN_VER_OPC; pintarLista(); };
const bc = $('#btnMarcarCosm'); if (bc) bc.onclick = () => { cos.forEach(r => r.marcado = true); pintarPlan(); }; const bc = $('#btnMarcarCosm'); if (bc) bc.onclick = () => { cos.forEach(r => r.marcado = true); pintarPlan(); };
const bb = $('#btnMarcarBanco'); if (bb) bb.onclick = () => { sin.forEach(r => r.marcado = true); pintarPlan(); };
// marcar o desmarcar una fila cambia las cifras, no la lista: así el foco se queda donde está // marcar o desmarcar una fila cambia las cifras, no la lista: así el foco se queda donde está
$$('#planLista .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; el.classList.toggle('sinmarcar', !r.marcado); pintarCabPlan(); estadoPlan(); }; }); $$('#planLista .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; el.classList.toggle('sinmarcar', !r.marcado); pintarCabPlan(); estadoPlan(); }; });
$$('#planLista [data-ej]').forEach(b => b.onclick = () => { const i = ejemplosDe(PLAN_PASO).findIndex(x => x.id === +b.dataset.ej); if (i >= 0) { PLAN_EJ = i; pintarEjemplo(); pintarLista(); $('#ejCaja').scrollIntoView({behavior: 'smooth', block: 'nearest'}); } }); $$('#planLista [data-ej]').forEach(b => b.onclick = () => { const i = ejemplosDe(PLAN_PASO).findIndex(x => x.id === +b.dataset.ej); if (i >= 0) { PLAN_EJ = i; pintarEjemplo(); pintarLista(); $('#ejCaja').scrollIntoView({behavior: 'smooth', block: 'nearest'}); } });