1.8.0: red de seguridad tras la auditoría
- Deshacer vuelve a la interfaz (Historial, Últimos cambios y el aviso al aplicar). - «Elements» ya no marca como stock lo que tenga dentro: 50,7 GB de material propio dejaban de respaldarse y salían como desechables. - El plan contrasta el mes de la carpeta con la fecha de los archivos y avisa cuando no cuadran (727 GB en el material real iban al mes equivocado en verde). - aplicar(): un archivo bloqueado ya no corta el resto de su grupo. - Un escaneo interrumpido no reemplaza el inventario bueno. - Las uniones de carpeta de Windows no se recorren (colgaban el escaneo). - Abrir una carpeta pasó de 8,83 s a 0,21 s. - Plantillas: _Papelera migra a _Para borrar. - .dat/.idx/.bak dejan de ser basura; Cache deja de ser proxies; abreviaturas de mes bajan a confianza media. - Convertidor: varios formatos por clip, originales apartados fuera de candidatos, sufijo en modo espejo, temporal borrado al fallar, una sola cola. - Interfaz: accion() no pisa el estado del botón; rutas de Windows bien recortadas. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
30
README.md
30
README.md
@@ -302,6 +302,36 @@ diálogo en un PowerShell aparte para no bloquear el servidor y devuelve la ruta
|
||||
- Barra de tareas revisada: la conversión en curso llegaba como un id y la interfaz esperaba un objeto (salía
|
||||
0 % sin nombre); ahora `estado.cola` trae `actual` (ruta, pct, raíz), `hechos` y `total`.
|
||||
|
||||
1.8.0, auditoría y red de seguridad. Tras revisar el código con cuatro pasadas y medir sobre el material real
|
||||
(7,73 TB, 53.964 archivos):
|
||||
- **Deshacer vuelve a la interfaz.** Al rediseñar el Historial en la 1.6.0 se quitaron los botones pero la app
|
||||
seguía prometiendo en cuatro pantallas que «se deshace desde Historial». Ahora está en el Historial, en
|
||||
«Últimos cambios» y en el aviso que sale al aplicar («Deshacer esto»).
|
||||
- **«Elements» ya no convierte en stock lo que tenga dentro.** La marca solo la hereda una carpeta con nombre
|
||||
completo de paquete de Envato; lo descargado se reconoce por el nombre del archivo. Medido: marcaba 1.713
|
||||
archivos y 50,7 GB de material propio como desechable, que el respaldo excluye por defecto.
|
||||
- **El plan contrasta el mes de la carpeta con la fecha de los archivos** (`_discrepa_mes`, `Plan.mes_real`).
|
||||
Si no cuadran, baja a «probable», no se marca y sale un aviso arriba con los dos meses. 5 de las 10 carpetas
|
||||
de mes con material fechable no contenían el mes que decían: son 727 GB. Los nombres Blackmagic no traen
|
||||
año, así que en esos solo se compara el mes.
|
||||
- **Un archivo bloqueado ya no corta el resto de su grupo** en `aplicar()`: cada archivo va por su cuenta y el
|
||||
resumen dice cuántos fallaron de verdad (antes uno abierto en Resolve dejaba sin mover los 117 restantes).
|
||||
- **Un escaneo interrumpido no reemplaza el inventario:** si hubo fallos de lectura y el recuento cae más de
|
||||
un 10 % frente al anterior, se conserva el que había y se explica por qué.
|
||||
- **Las uniones de carpeta de Windows no se recorren** (`_es_enlace`, `_id_carpeta`). Una circular dejaba el
|
||||
escaneo dando vueltas sin fin: comprobado, un árbol de un archivo no terminaba en dos minutos.
|
||||
- **Abrir una carpeta pasó de 8,83 s a 0,21 s:** `_resumen_carpetas` calcula de una sola pasada lo que antes
|
||||
era una consulta por carpeta que recorría la tabla entera.
|
||||
- **La papelera se llama igual en el disco que en pantalla.** Las plantillas guardadas con el nombre viejo
|
||||
(`_Papelera`) se migran a `_Para borrar` al arrancar.
|
||||
- Falsos positivos quitados: `.dat`, `.idx` y `.bak` ya no son basura de sistema, y una carpeta llamada
|
||||
`Cache` a secas ya no son proxies. Las abreviaturas de mes («Mar», «May») bajan a confianza media.
|
||||
- Convertidor: un clip ya convertido a un formato puede convertirse a otro (el filtro «en cola» miraba todos
|
||||
los trabajos); los originales ya apartados no vuelven a salir como candidatos; en modo espejo cada formato
|
||||
tiene su propio archivo; el temporal se borra si falla; y la cola no puede arrancar dos veces.
|
||||
- Interfaz: los botones ya no vuelven a su etiqueta vieja pisando lo que la acción acaba de calcular, y las
|
||||
rutas de Windows se recortan bien (soltar un archivo registraba el archivo como carpeta de cliente).
|
||||
|
||||
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
|
||||
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
|
||||
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"nombre": "OrganiLAB",
|
||||
"version": "1.7.0",
|
||||
"builtAt": "2026-09-16T01:45:56.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-1.7.0.zip",
|
||||
"size": 198673691,
|
||||
"sha256": "2dad6903c9484815406c4d0f3e2e3ebf65a916990fc93ee3ade07ac489bd7f9e",
|
||||
"version": "1.8.0",
|
||||
"builtAt": "2026-09-16T15:38:23.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-1.8.0.zip",
|
||||
"size": 198682459,
|
||||
"sha256": "7975c51317de5c63aa8271a11434d183c783893a234718d24151264638fee751",
|
||||
"app": {
|
||||
"archivo": "OrganizadorLabstream-app-1.7.0.zip",
|
||||
"size": 14853941,
|
||||
"sha256": "faf85782a465f0d1cec062f6d7993e70e4e292adf2095f9864cbfafe4f33c935",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-1.7.0.zip"
|
||||
"archivo": "OrganizadorLabstream-app-1.8.0.zip",
|
||||
"size": 14862710,
|
||||
"sha256": "e8ab00045256795e182563241c7f62a9582776d6f7effc866d3eab2013b7eb8b",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-1.8.0.zip"
|
||||
},
|
||||
"notes": "",
|
||||
"firma": {
|
||||
"alg": "rsa2048-pkcs1v15-sha256",
|
||||
"valor": "bba9a7041615d6b3fc47ecdfee39e6835ed09d8f06bba6629b46e23d8e91a57c84ebf82ad0c9cda49632143a8f0b9baf56698393363c197604687a01f477cda7877cc7f4f6368460a85c08aaa5a17c3e2205ee5f3db598b02592b372f7ea479ca722a1498d2c0c13d6f95c02c1e8a0982ac280bf00b1cbcd71ee2d840a8a8a08038fc45fe7195cbf6ad1f2ab562d619c67060af21028bfab9f8213d9f77a6e974646e7c4c597356b094bfdc379950d2015a75ea131beaf34b29aab710934863e44b7e52055ee2b6d2108c53b2fe1c0249746f00537085a1d2ba051f653091d3b7fa9d523a3ba3d459f4c48ea3985a798d29214a985575e263cdebc4fac4cd493"
|
||||
"valor": "41a83ab9e9918fe7d16ca9559a7524e1c88685223196c3c85b2f32ea0f71da819d8ae3b099c68fbbabc3e276782a92dcf8d9ea42facaf3ae594f3062297cec7770e2dc885f15ba083dc28e7fd5422ed4abd11ead1ebae7ef7c69a34018c88d8ac5e4bcb290c19ee789261001663234b7ae9d3372a7951947dbfe367cebb3a323f0688a8a177f07cc8827e8d293b8ac014a4f4348cabead577aa02d41d64a6f96af50c2cc70de6b809a3a4bda2dc13d6ee07c64924e19d02d5100600cdc9be5a273a121757a111c5ff20229f090306a39494e280679913d347843ba5193ab36d4f12b992f352e578815e1e535fc542f032ade94d16922bab8fc8f63c85bc760ba"
|
||||
}
|
||||
}
|
||||
298
organizador.py
298
organizador.py
@@ -17,6 +17,7 @@ import json
|
||||
import time
|
||||
import queue
|
||||
import shutil
|
||||
import stat as stat_mod
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import threading
|
||||
@@ -33,7 +34,7 @@ import actualizador
|
||||
asistente.O = sys.modules[__name__]
|
||||
actualizador.O = sys.modules[__name__]
|
||||
|
||||
VERSION = "1.7.0"
|
||||
VERSION = "1.8.0"
|
||||
|
||||
CONGELADO = getattr(sys, "frozen", False)
|
||||
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
|
||||
@@ -238,6 +239,11 @@ for t, d in R.PLANTILLAS.items():
|
||||
PLANTILLAS.setdefault(t, d)
|
||||
for b, p in d.items():
|
||||
PLANTILLAS[t].setdefault(b, p)
|
||||
# la papelera se llamaba «_Papelera» y pasó a «_Para borrar», pero las plantillas guardadas ganan
|
||||
# sobre las de fábrica, así que las instalaciones viejas seguían escribiendo en la carpeta vieja
|
||||
# mientras toda la interfaz hablaba de la nueva. Se migra una sola vez.
|
||||
if PLANTILLAS[t][b].startswith("_Papelera/") and p.startswith(R.PAPELERA + "/"):
|
||||
PLANTILLAS[t][b] = p
|
||||
guardar_json(PLANTILLAS_PATH, PLANTILLAS)
|
||||
|
||||
|
||||
@@ -354,10 +360,28 @@ def contexto_dir(nombre, ctx_padre, nivel_padre):
|
||||
return cub, nivel_padre
|
||||
|
||||
|
||||
def _es_enlace(entrada):
|
||||
"""True si la entrada es un enlace o una unión de Windows (mklink /D o /J). `is_symlink` no basta:
|
||||
para una unión devuelve False, y aun así al entrar se recorre otro árbol."""
|
||||
try:
|
||||
if entrada.is_symlink():
|
||||
return True
|
||||
return bool(entrada.stat(follow_symlinks=False).st_file_attributes & stat_mod.FILE_ATTRIBUTE_REPARSE_POINT)
|
||||
except (OSError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def _id_carpeta(ruta):
|
||||
"""Identificador real de una carpeta en el disco, para no recorrer dos veces la misma."""
|
||||
st = os.stat(ruta)
|
||||
return (st.st_dev, st.st_ino) if st.st_ino else os.path.normcase(os.path.abspath(ruta))
|
||||
|
||||
|
||||
def escanear(ruta):
|
||||
raiz = os.path.normpath(ruta)
|
||||
prog = PROGRESO[raiz] = {"estado": "corriendo", "n": 0, "bytes": 0, "inicio": time.time(), "actual": "", "error": ""}
|
||||
filas = []
|
||||
fallos = [] # carpetas o archivos que no se pudieron leer: deciden si el escaneo vale
|
||||
dirs = {} # ruta dir -> [n, bytes, Counter fechas, es_proxy, es_basura, nombre]
|
||||
props = ("n", "bytes")
|
||||
|
||||
@@ -369,10 +393,24 @@ def escanear(ruta):
|
||||
info[1] += size
|
||||
info[2][fecha_am] += 1
|
||||
|
||||
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes):
|
||||
def walk(path, en_proxy, en_basura, en_stock, ctx, nivel, anio, mes, vistos=None):
|
||||
# `vistos` lleva los identificadores reales de carpeta ya recorridos: una unión de Windows
|
||||
# (mklink /J) parece una carpeta normal, y una que apunte hacia arriba dejaba el escaneo dando
|
||||
# vueltas sin fin (comprobado: un árbol con un archivo no terminaba en dos minutos).
|
||||
if vistos is None:
|
||||
vistos = set()
|
||||
try:
|
||||
clave = _id_carpeta(path)
|
||||
if clave in vistos:
|
||||
fallos.append(f"{path}: es un acceso directo a una carpeta ya recorrida; no se entra")
|
||||
return
|
||||
vistos = vistos | {clave}
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
it = os.scandir(path)
|
||||
except OSError:
|
||||
except OSError as ex:
|
||||
fallos.append(f"{path}: no se pudo leer ({ex.strerror or ex})")
|
||||
return
|
||||
prog["actual"] = path
|
||||
with it:
|
||||
@@ -384,7 +422,12 @@ def escanear(ruta):
|
||||
continue
|
||||
p = en_proxy or R.es_proxy_dir(nombre)
|
||||
b = en_basura or R.es_basura_dir(nombre) or R.norm(nombre) in R.PAPELERAS_NORM
|
||||
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip())) or R.norm(nombre) in ("elements", "elemnts")
|
||||
# una carpeta llamada «Elements» NO basta para dar por desechable lo que tiene dentro:
|
||||
# medido el 16-sep-2026 sobre el inventario real, marcaba 1.713 archivos y 50,7 GB de
|
||||
# material propio (clips de cámara de hasta 1 GB) como stock, que el respaldo excluye
|
||||
# por defecto. Solo hereda la marca una carpeta con nombre completo de paquete de
|
||||
# Envato; lo que de verdad se descargó se reconoce por el nombre del propio archivo.
|
||||
s = en_stock or bool(R._RX_ENVATO_DIR.search(nombre.strip()))
|
||||
c2, n2 = contexto_dir(nombre, ctx, nivel)
|
||||
a2, m2 = anio, mes
|
||||
if c2 == "ANIO":
|
||||
@@ -394,7 +437,10 @@ def escanear(ruta):
|
||||
dirs.setdefault(e.path, [0, 0, Counter(), 0, 0, nombre])
|
||||
dirs[e.path][3] = 1 if p else 0
|
||||
dirs[e.path][4] = 1 if b else 0
|
||||
walk(e.path, p, b, s, c2, n2, a2, m2)
|
||||
if _es_enlace(e):
|
||||
fallos.append(f"{e.path}: es un acceso directo a otra carpeta; no se entra")
|
||||
continue
|
||||
walk(e.path, p, b, s, c2, n2, a2, m2, vistos)
|
||||
else:
|
||||
st = e.stat(follow_symlinks=False)
|
||||
ext = os.path.splitext(nombre)[1].lower()
|
||||
@@ -418,12 +464,26 @@ def escanear(ruta):
|
||||
if padre == d:
|
||||
break
|
||||
d = padre
|
||||
except OSError:
|
||||
except OSError as ex:
|
||||
fallos.append(f"{path}\\{e.name}: no se pudo leer ({ex.strerror or ex})")
|
||||
continue
|
||||
|
||||
try:
|
||||
walk(raiz, False, False, False, "", "raiz", None, None)
|
||||
prog["fallos"] = len(fallos)
|
||||
con = db()
|
||||
# Antes se borraba el inventario y se guardaba lo recogido pasara lo que pasara. Si una unidad de
|
||||
# red se caía a media lectura, el inventario quedaba truncado y marcado como bueno, y a partir de
|
||||
# ahí el plan, el respaldo y el convertidor trabajaban sobre datos parciales sin que nadie lo supiera.
|
||||
anterior = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
|
||||
if fallos and anterior and anterior["n"] and prog["n"] < anterior["n"] * 0.9:
|
||||
con.close()
|
||||
prog["estado"] = "error"
|
||||
prog["error"] = (f"el escaneo no pudo leer {len(fallos)} sitios y solo encontró {prog['n']:,} archivos "
|
||||
f"frente a {anterior['n']:,} del anterior: se conserva el inventario que ya había. "
|
||||
f"Comprueba que la unidad esté conectada. Primero que falló: {fallos[0]}")
|
||||
prog["fin"] = time.time()
|
||||
return
|
||||
with con:
|
||||
# conservar el análisis de ffprobe de los archivos que no cambiaron
|
||||
previo = {r["ruta"]: r for r in con.execute(
|
||||
@@ -458,6 +518,8 @@ def escanear(ruta):
|
||||
(raiz, time.time(), prog["n"], prog["bytes"], time.time() - prog["inicio"]))
|
||||
con.close()
|
||||
prog["estado"] = "listo"
|
||||
if fallos:
|
||||
prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})"
|
||||
PLANES.pop(raiz, None) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco
|
||||
except Exception as ex: # noqa
|
||||
prog["estado"] = "error"
|
||||
@@ -678,9 +740,14 @@ class Plan:
|
||||
m, conflicto = R.detectar_mes(nombre)
|
||||
if m:
|
||||
canon = R.nombre_mes(m)
|
||||
real, real_origen = self.mes_real(e.path)
|
||||
discrepa = bool(real and anio and _discrepa_mes("%04d-%02d-01" % real, real_origen, anio, m))
|
||||
if nombre != canon:
|
||||
motivo = "nombre de mes normalizado" + (" (el número no coincidía con el nombre: gana el nombre)" if conflicto else "")
|
||||
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo, "media" if conflicto else "alta")
|
||||
if discrepa:
|
||||
motivo += f" · OJO: lo que hay dentro parece de {R.nombre_mes(real[1])}"
|
||||
self.agregar(e.path, os.path.join(padre, canon), "MES", "renombrar", motivo,
|
||||
"media" if (conflicto or discrepa) else "alta")
|
||||
self.plan_dir(e.path, "mes", anio, m)
|
||||
return
|
||||
if nivel == "raiz" or (nivel == "contenedor" and not anio):
|
||||
@@ -780,6 +847,24 @@ class Plan:
|
||||
destino = destino_plantilla(self.tipo, cub, anio=fa, mes=fm, nombre=limpio, sub=sub, rel=self.rel(e.path))
|
||||
self.agregar(e.path, self.abs_(destino), cub, "mover carpeta", motivo, conf)
|
||||
|
||||
def mes_real(self, ruta):
|
||||
"""((año, mes), origen) que dicen los archivos de esta carpeta por su propio nombre, si están de
|
||||
acuerdo. (None, "") cuando la fecha solo sale de la fecha de modificación, que no es de fiar."""
|
||||
cnt = Counter()
|
||||
origenes = Counter()
|
||||
for f in archivos_bajo(self.con, ruta):
|
||||
if f["es_basura"] or f["es_proxy"] or f["fecha_origen"] == "mtime":
|
||||
continue
|
||||
if f["rol"] in ("camara_raw", "camara_video", "camara_sidecar", "foto_camara", "celular_video", "audio_grabado"):
|
||||
cnt[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
|
||||
origenes[f["fecha_origen"]] += 1
|
||||
if not cnt:
|
||||
return None, ""
|
||||
(a, m), n = cnt.most_common(1)[0]
|
||||
if n < 3 or n / sum(cnt.values()) < 0.7:
|
||||
return None, ""
|
||||
return (a, m), origenes.most_common(1)[0][0]
|
||||
|
||||
def plan_grabacion(self, e, nivel, anio, mes, motivo):
|
||||
"""Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto)."""
|
||||
nombre = e.name
|
||||
@@ -789,6 +874,7 @@ class Plan:
|
||||
if not filas:
|
||||
return
|
||||
grupos = defaultdict(list)
|
||||
desacuerdo = Counter() # (año, mes) que dicen los archivos cuando no cuadran con la carpeta
|
||||
for f in filas:
|
||||
if f["es_basura"] or f["es_proxy"]:
|
||||
continue
|
||||
@@ -810,7 +896,14 @@ class Plan:
|
||||
elif R.detectar_mes(seg)[0]:
|
||||
mes_ruta = R.detectar_mes(seg)[0]
|
||||
if anio and mes:
|
||||
# La carpeta dice un mes, pero los archivos pueden decir otro. Medido el 16-sep-2026 sobre
|
||||
# el material real: 5 de las 10 carpetas de mes con material fechable no contenían el mes
|
||||
# que anunciaban (una llamada «02-marzo» tenía 119 clips de diciembre). Antes esto se movía
|
||||
# con confianza alta y sin aviso; ahora se avisa y baja a media.
|
||||
ay, am, fconf = anio, mes, "alta"
|
||||
if _discrepa_mes(f["fecha"], f["fecha_origen"], anio, mes):
|
||||
fconf = "media"
|
||||
desacuerdo[(int(f["fecha"][:4]), int(f["fecha"][5:7]))] += 1
|
||||
else:
|
||||
ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7])
|
||||
por_nombre = f["fecha_origen"] != "mtime"
|
||||
@@ -830,6 +923,16 @@ class Plan:
|
||||
ay -= 1
|
||||
grupos[(ay, am, fuente, fconf)].append((f["ruta"], f["bytes"]))
|
||||
etiqueta = "" if generico else R.etiqueta_grabacion(nombre)
|
||||
aviso_fecha = ""
|
||||
# hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar
|
||||
# toda la carpeta sin marcar.
|
||||
n_fechables = sum(1 for f in filas if not f["es_basura"] and not f["es_proxy"] and f["fecha_origen"] != "mtime")
|
||||
if desacuerdo and sum(desacuerdo.values()) >= 3 and sum(desacuerdo.values()) >= 0.25 * max(1, n_fechables):
|
||||
(da, dm), cuantos = desacuerdo.most_common(1)[0]
|
||||
aviso_fecha = (f" · OJO: la carpeta dice {R.nombre_mes(mes)} pero {cuantos} de sus archivos son de "
|
||||
f"{R.nombre_mes(dm)} según su propio nombre; comprueba antes de aplicar")
|
||||
self.avisos.append(f"«{self.rel(e.path)}» dice {R.nombre_mes(mes)} de {anio} y el nombre de {cuantos} de "
|
||||
f"sus archivos dice {R.nombre_mes(dm)}: se propone sin marcar para que lo revises.")
|
||||
for (ay, am, fuente, fconf), archivos in grupos.items():
|
||||
sub = fuente if not etiqueta else os.path.join(etiqueta, fuente)
|
||||
destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, sub=sub, nombre=limpiar_nombre(nombre))
|
||||
@@ -837,7 +940,9 @@ class Plan:
|
||||
if all(os.path.normpath(os.path.dirname(a[0])).lower() == self.abs_(destino).lower() for a in archivos):
|
||||
continue
|
||||
mot = motivo + (" · repartido por fuente" if anio and mes else " · mes por la fecha de cada archivo" + (" (fecha de modificación)" if fconf == "baja" else ""))
|
||||
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot, fconf, archivos=archivos)
|
||||
marcado = False if aviso_fecha else None
|
||||
self.agregar(e.path, self.abs_(destino), "GRABACION", "mover archivos", mot + aviso_fecha, fconf,
|
||||
archivos=archivos, marcado=marcado)
|
||||
|
||||
def plan_archivo(self, e, nivel, anio, mes, padre):
|
||||
fila = self.con.execute("SELECT * FROM archivos WHERE ruta = ?", (e.path,)).fetchone()
|
||||
@@ -1020,20 +1125,40 @@ def aplicar(raiz, filas):
|
||||
APLICANDO.update(hechas=0, total=len(filas), actual="", raiz=_clave(raiz))
|
||||
for f in filas:
|
||||
APLICANDO.update(hechas=hechas, actual=f.get("origen_rel") or "")
|
||||
try:
|
||||
if f.get("archivos"):
|
||||
especiales = f.get("destinos_archivo") or {}
|
||||
if f.get("archivos"):
|
||||
# cada archivo va por su cuenta: antes, un clip abierto en Resolve cortaba el bucle y los
|
||||
# ciento y pico restantes de esa misma fila no se intentaban, mientras el resumen decía
|
||||
# que había fallado uno solo.
|
||||
especiales = f.get("destinos_archivo") or {}
|
||||
fallos_fila = 0
|
||||
try:
|
||||
if not especiales:
|
||||
os.makedirs(f["destino"], exist_ok=True)
|
||||
for a in f["archivos"]:
|
||||
if os.path.exists(a):
|
||||
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
|
||||
else:
|
||||
except Exception as ex: # noqa
|
||||
errores.append(f"{f.get('origen_rel')}: no se pudo crear la carpeta destino: {ex}")
|
||||
continue
|
||||
for a in f["archivos"]:
|
||||
if not os.path.exists(a):
|
||||
continue
|
||||
try:
|
||||
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
|
||||
except Exception as ex: # noqa
|
||||
fallos_fila += 1
|
||||
if fallos_fila <= 5:
|
||||
errores.append(f"{os.path.basename(a)}: {ex}")
|
||||
if fallos_fila > 5:
|
||||
errores.append(f"{f.get('origen_rel')}: y otros {fallos_fila - 5} archivos que tampoco se pudieron mover")
|
||||
if not fallos_fila:
|
||||
hechas += 1
|
||||
else:
|
||||
try:
|
||||
if os.path.exists(f["origen"]):
|
||||
mover(f["origen"], f["destino"], lote, movidos)
|
||||
hechas += 1
|
||||
except Exception as ex: # noqa
|
||||
errores.append(f"{f.get('origen_rel')}: {ex}")
|
||||
hechas += 1
|
||||
else:
|
||||
errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)")
|
||||
except Exception as ex: # noqa
|
||||
errores.append(f"{f.get('origen_rel')}: {ex}")
|
||||
APLICANDO.update(hechas=len(filas), actual="") # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo»
|
||||
# carpetas que quedaron vacías al vaciarlas (p. ej. "Grabacion" tras repartir sus archivos)
|
||||
for f in filas:
|
||||
@@ -1148,12 +1273,13 @@ def vocabulario(raiz):
|
||||
t["bytes"] += c["bytes"] or 0
|
||||
if len(t["rutas"]) < 4:
|
||||
t["rutas"].append(rel)
|
||||
if tokens:
|
||||
if tokens and any(not t["cubeta"] for t in tokens.values()):
|
||||
con = db()
|
||||
resumen = _resumen_carpetas(con, raiz)
|
||||
con.close()
|
||||
for t in tokens.values():
|
||||
if not t["cubeta"]:
|
||||
_sugerir(con, raiz, t)
|
||||
con.close()
|
||||
_sugerir(resumen, raiz, t)
|
||||
out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"]))
|
||||
return out
|
||||
|
||||
@@ -1162,27 +1288,64 @@ _ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición /
|
||||
"imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"}
|
||||
|
||||
|
||||
def _sugerir(con, raiz, t):
|
||||
def _discrepa_mes(fecha, origen, anio, mes):
|
||||
"""True si lo que dice el nombre del archivo no cuadra con el mes que anuncia la carpeta.
|
||||
Los nombres de Blackmagic (A006_12171629) traen mes y día pero NO año: el año sale de la fecha de
|
||||
modificación, que no es de fiar, así que en ese caso solo se compara el mes."""
|
||||
if origen == "mtime":
|
||||
return False # la fecha de modificación no prueba nada
|
||||
ra, rm = int(fecha[:4]), int(fecha[5:7])
|
||||
if origen == "nombre+mtime":
|
||||
d = abs(rm - mes)
|
||||
return min(d, 12 - d) >= 2 # solo el mes, y con dos meses de margen
|
||||
return abs((ra * 12 + rm) - (anio * 12 + mes)) >= 2
|
||||
|
||||
|
||||
def _clase_rol(rol):
|
||||
return ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
|
||||
"proyecto" if rol in ("proyecto", "diseno") else
|
||||
"audio" if rol == "audio" else
|
||||
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
|
||||
"documento" if rol == "documento" else
|
||||
"video" if rol in ("video", "celular_video") else "otro")
|
||||
|
||||
|
||||
def _resumen_carpetas(con, raiz):
|
||||
"""Para cada carpeta de la raíz, qué tipos de archivo tiene dentro contando subcarpetas.
|
||||
Se calcula de una sola pasada sumando hacia arriba: antes se hacía una consulta por carpeta y
|
||||
cada una recorría la tabla entera (abrir una raíz de 24.000 archivos tardaba 8,8 s; ahora 0,2 s)."""
|
||||
agg = defaultdict(lambda: defaultdict(lambda: [0, 0]))
|
||||
filas = con.execute("SELECT dir, rol, COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos "
|
||||
"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 GROUP BY dir, rol", (raiz,)).fetchall()
|
||||
tope = os.path.normcase(os.path.normpath(raiz))
|
||||
for f in filas:
|
||||
k = _clase_rol(f["rol"])
|
||||
d = os.path.normpath(f["dir"])
|
||||
while True:
|
||||
e = agg[os.path.normcase(d)][k]
|
||||
e[0] += f["n"]
|
||||
e[1] += f["b"]
|
||||
if os.path.normcase(d) == tope:
|
||||
break
|
||||
padre = os.path.dirname(d)
|
||||
if padre == d:
|
||||
break
|
||||
d = padre
|
||||
return agg
|
||||
|
||||
|
||||
def _sugerir(resumen, raiz, t):
|
||||
"""Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre.
|
||||
Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible)."""
|
||||
import difflib
|
||||
conteo = defaultdict(lambda: [0, 0])
|
||||
total = [0, 0]
|
||||
for rel in t["rutas"][:3]:
|
||||
for f in archivos_bajo(con, os.path.join(raiz, rel)):
|
||||
if f["es_basura"] or f["es_proxy"]:
|
||||
continue
|
||||
rol = f["rol"]
|
||||
k = ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else
|
||||
"proyecto" if rol in ("proyecto", "diseno") else
|
||||
"audio" if rol in ("audio",) else
|
||||
"imagen" if rol in ("imagen", "foto_camara", "celular_foto") else
|
||||
"documento" if rol == "documento" else
|
||||
"video" if rol in ("video", "celular_video") else "otro")
|
||||
conteo[k][0] += 1
|
||||
conteo[k][1] += f["bytes"]
|
||||
total[0] += 1
|
||||
total[1] += f["bytes"]
|
||||
for k, (n, b) in resumen.get(os.path.normcase(os.path.normpath(os.path.join(raiz, rel))), {}).items():
|
||||
conteo[k][0] += n
|
||||
conteo[k][1] += b
|
||||
total[0] += n
|
||||
total[1] += b
|
||||
orden = sorted(conteo.items(), key=lambda kv: -kv[1][1])
|
||||
t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas"
|
||||
sug, conf, por_que = "", "", ""
|
||||
@@ -1195,7 +1358,7 @@ def _sugerir(con, raiz, t):
|
||||
if total[0] >= 3 and orden:
|
||||
k, (n, b) = orden[0]
|
||||
frac = b / total[1] if total[1] else n / total[0]
|
||||
if frac >= 0.7:
|
||||
if frac >= 0.75:
|
||||
por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS",
|
||||
"documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k)
|
||||
if por_cont:
|
||||
@@ -1203,7 +1366,10 @@ def _sugerir(con, raiz, t):
|
||||
por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}"
|
||||
conf = "baja"
|
||||
else:
|
||||
sug, conf, por_que = por_cont, ("alta" if frac >= 0.9 and n >= 3 else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
|
||||
# «alta» se acepta de golpe con un botón y se guarda en el diccionario común a todos
|
||||
# los clientes: exige que sea contundente y que haya varias carpetas iguales.
|
||||
seguro = frac >= 0.92 and n >= 8 and total[0] >= 10
|
||||
sug, conf, por_que = por_cont, ("alta" if seguro else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)"
|
||||
t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que
|
||||
|
||||
|
||||
@@ -1428,9 +1594,14 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
con = db()
|
||||
limite = time.time() - int(meses) * 30.4 * 86400
|
||||
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND bytes >= ? ORDER BY bytes DESC",
|
||||
(raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||||
en_cola = {r["ruta"]: r["estado"] for r in con.execute("SELECT ruta, estado FROM trabajos").fetchall()}
|
||||
# es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
|
||||
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND es_basura = 0 "
|
||||
"AND bytes >= ? ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
|
||||
# solo bloquea el clip lo que está a medias EN ESTE MISMO formato: un clip ya pasado a ProRes debe
|
||||
# poder convertirse también a H.265, y uno que falló debe poder reintentarse.
|
||||
en_cola = {r["ruta"]: r["estado"] for r in con.execute(
|
||||
"SELECT ruta, estado FROM trabajos WHERE formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')",
|
||||
(formato,)).fetchall()}
|
||||
con.close()
|
||||
out = []
|
||||
for f in filas:
|
||||
@@ -1468,7 +1639,8 @@ def salida_para(ruta, raiz, formato="h265_10bit"):
|
||||
sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"]
|
||||
if o.get("salida") == "espejo":
|
||||
rel = os.path.relpath(base, raiz)
|
||||
return os.path.join(raiz, "_Archivo", rel + fm["ext"])
|
||||
# el sufijo también aquí: sin él, H.265 y H.264 del mismo clip iban al mismo archivo
|
||||
return os.path.join(raiz, "_Archivo", rel + sufijo + fm["ext"])
|
||||
return base + sufijo + fm["ext"]
|
||||
|
||||
|
||||
@@ -1600,6 +1772,10 @@ def correr_ffmpeg(t):
|
||||
return
|
||||
if p.returncode != 0 or not os.path.exists(tmp):
|
||||
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
|
||||
try:
|
||||
os.remove(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
os.replace(tmp, salida)
|
||||
try:
|
||||
@@ -1756,17 +1932,23 @@ class ErrorCola(ValueError):
|
||||
self.datos = datos
|
||||
|
||||
|
||||
_COLA_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def iniciar_cola():
|
||||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||||
return False
|
||||
recuperar_huerfanas()
|
||||
COLA["activa"] = True
|
||||
COLA["cancelar"] = False
|
||||
COLA["hechos"] = 0
|
||||
COLA["inicio"] = time.time()
|
||||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||||
COLA["hilo"].start()
|
||||
return True
|
||||
# el servidor atiende cada petición en un hilo: sin candado, encolar con «iniciar» a la vez que
|
||||
# alguien pulsa Reanudar arrancaba dos colas y dos ffmpeg sobre el mismo archivo temporal.
|
||||
with _COLA_LOCK:
|
||||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||||
return False
|
||||
recuperar_huerfanas()
|
||||
COLA["activa"] = True
|
||||
COLA["cancelar"] = False
|
||||
COLA["hechos"] = 0
|
||||
COLA["inicio"] = time.time()
|
||||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||||
COLA["hilo"].start()
|
||||
return True
|
||||
|
||||
|
||||
def verificar(ids):
|
||||
@@ -2159,7 +2341,7 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru
|
||||
por_tipo_res[it["tipo"]]["bytes"] += it["bytes"]
|
||||
omit_raw.sort(key=lambda x: -x["bytes"])
|
||||
return {
|
||||
"cliente": cliente, "base": base, "n": len(items), "bytes": sum(i["bytes"] for i in items),
|
||||
"cliente": cliente, "base": base, "destino_base": destino, "n": len(items), "bytes": sum(i["bytes"] for i in items),
|
||||
"por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])],
|
||||
"convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]},
|
||||
"stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b},
|
||||
@@ -2417,6 +2599,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/") and not self._es_local():
|
||||
return self._json({"error": "petición no permitida"}, 403)
|
||||
u = urlparse(self.path)
|
||||
q = {k: v[0] for k, v in parse_qs(u.query).items()}
|
||||
ruta = u.path
|
||||
@@ -2524,8 +2708,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"error": "indica la carpeta de destino del respaldo"}, 400)
|
||||
plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0))
|
||||
PLANES_RESPALDO[raiz] = plan
|
||||
CONFIG.setdefault("respaldo", {})["destino"] = destino
|
||||
guardar_json(CONFIG_PATH, CONFIG)
|
||||
# calcular no guarda nada: el destino se guarda al copiar (antes una simple lectura
|
||||
# reescribía config.json)
|
||||
return self._json({k: v for k, v in plan.items() if k != "items"})
|
||||
if ruta == "/api/respaldo/estado":
|
||||
return self._json(RESPALDO)
|
||||
@@ -2679,6 +2863,10 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
if ruta == "/api/abrir":
|
||||
return self._json(abrir_carpeta(d.get("ruta", "")))
|
||||
if ruta == "/api/respaldo/copiar":
|
||||
_rp = PLANES_RESPALDO.get(os.path.normpath(d.get("raiz", "")))
|
||||
if _rp and _rp.get("destino_base"):
|
||||
CONFIG.setdefault("respaldo", {})["destino"] = _rp["destino_base"]
|
||||
guardar_json(CONFIG_PATH, CONFIG)
|
||||
raiz = os.path.normpath(d["raiz"])
|
||||
plan = PLANES_RESPALDO.get(raiz)
|
||||
if not plan:
|
||||
|
||||
15
reglas.py
15
reglas.py
@@ -29,6 +29,7 @@ def norm(s: str) -> str:
|
||||
MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
|
||||
"agosto", "septiembre", "octubre", "noviembre", "diciembre"]
|
||||
MESES_TITULO = [m.capitalize() for m in MESES]
|
||||
# abreviaturas: se entienden, pero nunca con confianza alta (ver MESES_DUDOSOS)
|
||||
MESES_ALIAS = {
|
||||
"setiembre": "septiembre", "septiempre": "septiembre", "sept": "septiembre",
|
||||
"sep": "septiembre", "ene": "enero", "feb": "febrero", "mar": "marzo",
|
||||
@@ -38,6 +39,8 @@ MESES_ALIAS = {
|
||||
"june": "junio", "july": "julio", "august": "agosto", "september": "septiembre",
|
||||
"october": "octubre", "november": "noviembre", "december": "diciembre",
|
||||
}
|
||||
# «Mar», «May» o «Ago» pueden ser un nombre propio, no un mes: se proponen con confianza media
|
||||
MESES_DUDOSOS = {"sep", "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "oct", "nov", "dic"}
|
||||
|
||||
|
||||
def detectar_anio(nombre: str):
|
||||
@@ -55,10 +58,12 @@ def detectar_mes(nombre: str):
|
||||
return None, False
|
||||
num = int(m.group(1)) if m.group(1) else None
|
||||
palabra = m.group(2)
|
||||
abreviada = palabra in MESES_DUDOSOS
|
||||
palabra = MESES_ALIAS.get(palabra, palabra)
|
||||
mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None
|
||||
if mes_por_nombre:
|
||||
return mes_por_nombre, (num is not None and num != mes_por_nombre)
|
||||
# abreviada sin número («Mar») o número que no cuadra con el nombre: confianza media
|
||||
return mes_por_nombre, (num is not None and num != mes_por_nombre) or (abreviada and num is None)
|
||||
if num and 1 <= num <= 12 and not palabra:
|
||||
return num, True # «3» solo puede ser marzo… o la pieza número 3: se propone con confianza media, no alta
|
||||
return None, False
|
||||
@@ -153,10 +158,14 @@ EXT = {
|
||||
"programa": {".exe", ".msi", ".pkg", ".app", ".lnk"},
|
||||
}
|
||||
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"}
|
||||
BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk", ".bak", ".ims", ".idx", ".dat"}
|
||||
# solo temporales y cachés que un programa regenera solo. NO se ponen aquí extensiones de datos
|
||||
# (.dat, .idx, .bak): son de plugins, subtítulos o copias que la gente guarda a propósito.
|
||||
BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk"}
|
||||
BASURA_DIRS = {"__macosx", "#recycle", ".synologyworkingdirectory", "@eadir", "media cache", "media cache files", "peak files"}
|
||||
BASURA_DIRS_CONTIENE = ["almacenamiento automatico", "auto save", "autosave", "auto-save", "adobe premiere pro auto"]
|
||||
PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media", "cache"}
|
||||
# «cache» a secas no entra: es un nombre demasiado genérico para mandar la carpeta entera a _Para borrar
|
||||
PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media",
|
||||
"cache files", "render cache"}
|
||||
|
||||
|
||||
def es_basura_archivo(nombre: str) -> bool:
|
||||
|
||||
@@ -437,7 +437,7 @@
|
||||
<!-- ============ HISTORIAL ============ -->
|
||||
<section id="t-historial">
|
||||
<div class="cab"><h2 class="v">Historial</h2><div class="der"><label class="mut" style="font-size:12.5px"><input type="checkbox" id="bitSoloRaiz" checked> solo esta carpeta</label></div></div>
|
||||
<p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, con ejemplos de qué fue a dónde. <span class="q">?</span><span class="mas">La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p>
|
||||
<p class="ayuda">Cada vez que la app mueve algo lo apunta aquí, y <b>Deshacer</b> devuelve cada archivo a donde estaba. <span class="q">?</span><span class="mas">Si alguien movió un archivo después, ese se salta y se avisa. La app nunca borra: lo apartado en <span class="path">_Para borrar</span> lo revisa y lo borra una persona desde el Explorador.</span></p>
|
||||
<div id="papeleraLinea" class="hint" style="display:none"></div>
|
||||
<div id="historial"></div>
|
||||
</section>
|
||||
@@ -503,10 +503,14 @@ function aviso(texto, tipo, enlace, detalle){
|
||||
}
|
||||
async function accion(btn, fn){
|
||||
if (btn.disabled) return;
|
||||
const antes = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="spin"></span>' + antes;
|
||||
// se marca lo que pusimos nosotros: si la acción deja OTRA etiqueta (porque recalculó el plan, vació la
|
||||
// lista o volvió a bloquear el botón), esa manda. Antes el finally la pisaba y quedaban botones
|
||||
// habilitados con la cuenta vieja: «Aplicar todo lo marcado (23)» sobre un plan que ya no tenía 23.
|
||||
const antes = btn.innerHTML, marca = '<span class="spin"></span>' + antes;
|
||||
btn.disabled = true; btn.innerHTML = marca;
|
||||
try { await fn(); }
|
||||
catch (e) { console.error(e); aviso(e instanceof ApiError ? e.message : 'Algo falló: ' + (e.message || e), 'bad', null, detalleDe(e)); }
|
||||
finally { btn.disabled = false; btn.innerHTML = antes; }
|
||||
finally { if (btn.innerHTML === marca) { btn.disabled = false; btn.innerHTML = antes; } }
|
||||
}
|
||||
// qué se guarda como detalle de un error: la traza del servidor si la mandó, o la pila del navegador si fue aquí
|
||||
const detalleDe = e => (e && e.datos && e.datos.traza) ? String(e.datos.traza) : (e instanceof ApiError ? '' : String((e && e.stack) || ''));
|
||||
@@ -578,9 +582,10 @@ function huella(E){
|
||||
let SONDEO = null;
|
||||
async function sondear(){ if (SONDEO) return; SONDEO = cargarEstado().catch(e => console.warn(e)).finally(() => { SONDEO = null; }); }
|
||||
setInterval(sondear, 2500);
|
||||
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
|
||||
$('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; VOC = []; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); };
|
||||
function limpiarTablas(){
|
||||
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; estadoPlan();
|
||||
$('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; PLAN_ABIERTOS = new Set();
|
||||
$('#planIntro').innerHTML = 'Pulsa <b>Calcular</b> para ver, paso a paso, qué haría la app en esta carpeta.'; estadoPlan();
|
||||
$('#optCards').innerHTML = ''; $('#anaEstado').textContent = ''; $('#convLista').innerHTML = '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>'; bloquear('btnEncolar', 'x'); $('#btnEncolar').textContent = 'Convertir';
|
||||
$('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero');
|
||||
$('#respCards').innerHTML = ''; $('#respTipos').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respRawLista').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respMuestra').innerHTML = '<span class="mut">calcula para verlo</span>'; $('#respEstado').textContent = ''; bloquear('btnRespCopiar', 'Calcula primero');
|
||||
@@ -594,7 +599,7 @@ function tareasDe(E){
|
||||
if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); }
|
||||
if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); }
|
||||
});
|
||||
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
|
||||
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
|
||||
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
|
||||
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)});
|
||||
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
|
||||
@@ -690,7 +695,8 @@ async function cargarCarpeta(){
|
||||
$('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : '');
|
||||
}
|
||||
const lotes = CARPETA.lotes || [];
|
||||
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span></div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
|
||||
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && l.lote.startsWith('optimizar') ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
|
||||
$$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
|
||||
}
|
||||
function queSeHizo(lote){
|
||||
if (!lote) return 'Cambio';
|
||||
@@ -886,7 +892,8 @@ function fraseDe(r){
|
||||
function pintarPlan(){
|
||||
if (!PLAN) { estadoPlan(); return; }
|
||||
const f = ($('#filtroPlan').value || '').toLowerCase();
|
||||
$('#planAvisos').innerHTML = (PLAN.avisos || []).map(a => `<div class="hint warn">${esc(a)}</div>`).join('');
|
||||
const avs = PLAN.avisos || [];
|
||||
$('#planAvisos').innerHTML = avs.length ? `<div class="hint warn"><b>${avs.length === 1 ? 'Revisa esto antes de aplicar' : 'Revisa estas ' + avs.length + ' cosas antes de aplicar'}:</b><ul style="margin:6px 0 0;padding-left:18px">${avs.map(a => `<li>${esc(a)}</li>`).join('')}</ul></div>` : '';
|
||||
const porPaso = {}; PASOS_PLAN.forEach(p => porPaso[p.k] = []);
|
||||
PLAN.filas.forEach(r => (porPaso[r.paso || 'resto'] = porPaso[r.paso || 'resto'] || []).push(r));
|
||||
const tot = PLAN.filas.length, totB = PLAN.filas.reduce((a, r) => a + r.bytes, 0), dud = PLAN.filas.filter(r => r.confianza === 'baja').length;
|
||||
@@ -912,11 +919,11 @@ function pintarPlan(){
|
||||
$('#filtroPlan').oninput = pintarPlan;
|
||||
function aplicarFilas(m, btn, titulo){
|
||||
if (!m.length) return;
|
||||
modal(titulo, `<p>Se mueven <b>${m.length}</b> cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra.</b> Es un cambio de sitio dentro del mismo disco. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no bloqueen archivos.</p>`, () => accion(btn, async () => {
|
||||
modal(titulo, `<p>Se mueven <b>${m.length}</b> cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de <span class="path">${esc(PLAN.raiz)}</span>.</p><p><b>Nada se borra</b> y se puede deshacer desde el Historial. Es un cambio de sitio dentro del mismo disco. <b>Cierra Resolve y Premiere si tienen abierto material de esta carpeta</b> para que no bloqueen archivos.</p>`, () => accion(btn, async () => {
|
||||
const destinos = {}; m.forEach(r => { if (r.editado) destinos[r.id] = r.destino_rel; });
|
||||
const res = await api('/api/aplicar', {raiz: PLAN.raiz, ids: m.map(r => r.id), destinos});
|
||||
PLAN = null; MAPA = null;
|
||||
aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Ver historial', ir: () => irA('historial')});
|
||||
aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Deshacer esto', ir: () => deshacer(res.lote)});
|
||||
if (res.errores.length) modal('Algunos no se pudieron mover', `<pre>${esc(res.errores.join('\n'))}</pre>`, null);
|
||||
await cargarEstado(); await calcularPlan();
|
||||
}));
|
||||
@@ -1078,7 +1085,8 @@ async function cargarBit(){
|
||||
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
|
||||
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
|
||||
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
|
||||
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span><span></span></div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
|
||||
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
|
||||
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
|
||||
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
|
||||
}
|
||||
$('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad'));
|
||||
@@ -1132,7 +1140,7 @@ document.addEventListener('drop', seguro(async e => {
|
||||
if (window.pywebview || (window.chrome && window.chrome.webview)) {
|
||||
modal('Leyendo la carpeta…', `<p><span class="spin"></span> «${esc(nombre)}»</p>`, null);
|
||||
const s = await api('/api/soltado?nombre=' + encodeURIComponent(nombre));
|
||||
if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\/][^\/]*$/, '') : s.ruta);
|
||||
if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\\/][^\\/]*$/, '') : s.ruta);
|
||||
}
|
||||
modal('Buscando la carpeta…', `<p><span class="spin"></span> El navegador solo entrega el nombre («${esc(nombre)}»). Buscando en los discos…</p>`, null);
|
||||
const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre));
|
||||
|
||||
Referencia en una nueva incidencia
Block a user