1.8.1: fase 2 de la auditoría

- Plan.marcar_colisiones: dos movimientos que acabarían en el mismo archivo se
  detectan antes de aplicar, se dejan sin marcar y se explican.
- agregar(): los grupos de archivos también comprueban si el destino ya tiene
  un archivo con ese nombre (40 primeros, para no saturar la red).
- actualizar_inventario: las filas de carpetas se reescriben con su ruta nueva
  en vez de borrarse, y se conserva la marca de basura/proxy heredada, para que
  aplicar por pasos no deje los siguientes con 0 archivos y sin fecha.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 10:49:36 -05:00
padre b977fe1b3e
commit b492881123
Se han modificado 3 ficheros con 100 adiciones y 16 borrados

Ver fichero

@@ -332,6 +332,18 @@ diálogo en un PowerShell aparte para no bloquear el servidor y devuelve la ruta
- 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).
1.8.1, fase 2 de la auditoría:
- **Dos movimientos que acabarían en el mismo archivo se detectan antes de aplicar** (`Plan.marcar_colisiones`).
Caso real: dos carpetas de grabación genéricas del mismo mes van las dos a `00_Grabacion/SONY` y las dos
traen un `C0001.MP4`. Ahora salen sin marcar, en rojo, con un aviso que nombra las dos carpetas y el archivo
que choca. También se comprueba si el destino ya tiene un archivo con ese nombre en el disco (antes solo se
miraba en las filas de carpeta, no en los grupos de archivos); se revisan los 40 primeros de cada grupo para
no llenar la red de consultas.
- **Aplicar por pasos ya no degrada el inventario.** `actualizar_inventario` reescribe las filas de `carpetas`
con su ruta nueva en vez de borrarlas, y conserva la marca de basura/proxy heredada. Antes, como la interfaz
recalcula el plan sin volver a escanear, los pasos siguientes salían con «0 archivos · 0 B» y sin fecha.
Comprobado: tras aplicar el paso de fechas, la fila del paso 3 conserva sus 3 archivos y su peso.
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -1,19 +1,19 @@
{
"nombre": "OrganiLAB",
"version": "1.8.0",
"builtAt": "2026-09-16T15:38:23.000Z",
"archivo": "OrganizadorLabstream-portatil-1.8.0.zip",
"size": 198682459,
"sha256": "7975c51317de5c63aa8271a11434d183c783893a234718d24151264638fee751",
"version": "1.8.1",
"builtAt": "2026-09-16T15:47:26.000Z",
"archivo": "OrganizadorLabstream-portatil-1.8.1.zip",
"size": 198686206,
"sha256": "f001ba76c553d379f73d7928314d2f187788a8060e3730978b5a67174f2e660f",
"app": {
"archivo": "OrganizadorLabstream-app-1.8.0.zip",
"size": 14862710,
"sha256": "e8ab00045256795e182563241c7f62a9582776d6f7effc866d3eab2013b7eb8b",
"url": "/api/herramientas/OrganizadorLabstream-app-1.8.0.zip"
"archivo": "OrganizadorLabstream-app-1.8.1.zip",
"size": 14866457,
"sha256": "b3ec0d410e06102e928ed459bfa0a0f1c6e7713c6a807b18357fc7ef9fca49c7",
"url": "/api/herramientas/OrganizadorLabstream-app-1.8.1.zip"
},
"notes": "",
"firma": {
"alg": "rsa2048-pkcs1v15-sha256",
"valor": "41a83ab9e9918fe7d16ca9559a7524e1c88685223196c3c85b2f32ea0f71da819d8ae3b099c68fbbabc3e276782a92dcf8d9ea42facaf3ae594f3062297cec7770e2dc885f15ba083dc28e7fd5422ed4abd11ead1ebae7ef7c69a34018c88d8ac5e4bcb290c19ee789261001663234b7ae9d3372a7951947dbfe367cebb3a323f0688a8a177f07cc8827e8d293b8ac014a4f4348cabead577aa02d41d64a6f96af50c2cc70de6b809a3a4bda2dc13d6ee07c64924e19d02d5100600cdc9be5a273a121757a111c5ff20229f090306a39494e280679913d347843ba5193ab36d4f12b992f352e578815e1e535fc542f032ade94d16922bab8fc8f63c85bc760ba"
"valor": "b20e039a9f2a3234ab82a41f69e46583128e671b16ae5ea0bf73356ec9bbc34289e8418737c81d459c20a2cc7c0819c74a066680a83d0f9b88e4a5f5c3db0ff7a3c2d3376ccee53755e0600a063ee951f63048e1cadc5afeaba6e4895a450791e247dc888e3e3a510a36268c7ae4111c20521d8eb41677b0e482de857bbcb165963c0e8d208c076c19e6e75766967eddd62d5a2293217f88e54986c7b4062a75ee3fc2fe153680ca875e9a85d074b2845fe480b9bb32d9f6cfceecbb850bbfc11c81caf81ded6bb7063b88861263ba8d392911355cbeb4f772058754f26823b577885585ae0de6635515a325cfe4404d905ef38a4ff9b0100bd2024195833b92"
}
}

Ver fichero

@@ -34,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "1.8.0"
VERSION = "1.8.1"
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
@@ -618,7 +618,8 @@ class Plan:
return r
return None
def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None, marcado=None):
def agregar(self, origen, destino, cubeta, accion, motivo, confianza, archivos=None, n=None, bytes_=None,
marcado=None, kw_destinos=None):
destino = os.path.normpath(destino)
if archivos is None and os.path.normpath(origen).lower() == destino.lower():
return
@@ -630,7 +631,19 @@ class Plan:
fila = self.con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.normpath(origen),)).fetchone()
n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0)
aviso_colision = ""
if archivos is None and accion != "renombrar" and os.path.isfile(destino):
if archivos is not None and accion != "renombrar":
# también para los grupos de archivos: si el destino ya tiene un archivo con ese nombre,
# mover chocaría. Antes solo se comprobaba en las filas de carpeta.
especiales = kw_destinos or {}
# solo los primeros: cada comprobación es una consulta al disco de red, y para avisar
# basta con encontrar uno
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 choques:
aviso_colision = (f" · OJO: en el destino ya hay archivos con el mismo nombre "
f"(«{choques[0]}»): esos no se moverían")
confianza, marcado = "baja", False
elif archivos is None and accion != "renombrar" and os.path.isfile(destino):
aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá"
confianza, marcado = "baja", False
elif archivos is None and os.path.isdir(destino) and os.path.isdir(origen):
@@ -675,10 +688,46 @@ class Plan:
# 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
self.filas.sort(key=lambda f: (0 if f["archivos"] else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
self.marcar_colisiones()
for i, f in enumerate(self.filas, 1):
f["id"] = i
return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos}
def marcar_colisiones(self):
"""Dos movimientos del mismo plan que acaban en el mismo archivo. La comprobación de `agregar`
solo mira el disco tal como está ahora, así que no ve lo que van a hacer las demás filas.
Caso típico: «Grabacion mañana» y «Grabacion tarde» del mismo mes van las dos a
00_Grabacion/SONY, y las dos tarjetas traen un C0001.MP4. Antes chocaban al aplicar."""
destinos = {} # archivo destino (en minúsculas) -> primera fila que lo reclama
carpetas = {} # carpeta destino -> primera fila que la reclama (solo renombrados)
for f in self.filas:
if f["archivos"]:
especiales = f.get("destinos_archivo") or {}
for a in f["archivos"]:
d = (especiales.get(a) or os.path.join(f["destino"], os.path.basename(a))).lower()
otra = destinos.get(d)
if otra is not None and otra is not f:
self.avisar_colision(f, otra, os.path.basename(d))
break
destinos.setdefault(d, f)
elif f["accion"] == "renombrar":
d = f["destino"].lower()
otra = carpetas.get(d)
if otra is not None:
self.avisar_colision(f, otra, os.path.basename(f["destino"]))
else:
carpetas[d] = f
def avisar_colision(self, fila, otra, que):
aviso = f" · OJO: «{otra['origen_rel']}» también va a «{fila['destino_rel']}» y las dos traen «{que}»"
if aviso not in fila["motivo"]:
fila["motivo"] += aviso
fila["confianza"] = "baja"
fila["marcado"] = False
self.avisos.append(f"«{fila['origen_rel']}» y «{otra['origen_rel']}» acabarían las dos en "
f"«{fila['destino_rel']}» con archivos del mismo nombre ({que}). Se dejan sin marcar: "
f"cámbiale el destino a una de las dos, o aplícalas por separado.")
def entradas(self, ruta):
try:
with os.scandir(ruta) as it:
@@ -1171,20 +1220,43 @@ def aplicar(raiz, filas):
def actualizar_inventario(movidos):
"""refleja en la base de datos los movimientos hechos, sin re-escanear"""
"""Refleja en la base de datos los movimientos hechos, sin re-escanear.
Las filas de `carpetas` se reescriben con su ruta nueva en vez de borrarse: la interfaz recalcula el
plan tras cada paso SIN volver a escanear, y si el subárbol movido desaparecía del inventario, los
pasos siguientes salían con «0 archivos · 0 B» y sin fecha. La marca de basura/proxy heredada de una
carpeta (por ejemplo lo que cuelga de «Adobe Premiere Pro Auto-Save») también se conserva: antes se
recalculaba desde cero y se perdía."""
con = db()
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
with con:
for de, a in movidos:
en_papelera = 1 if any(m in a for m in marcas) else 0
con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, es_basura = CASE WHEN ? = 1 THEN 1 WHEN rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, "
"es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
(a, os.path.dirname(a), os.path.basename(a), en_papelera, de))
pref = de + os.sep
for r in con.execute("SELECT ruta FROM archivos WHERE ruta LIKE ? ESCAPE '!'", (esc_like(pref) + "%",)).fetchall():
nueva = a + r["ruta"][len(de):]
con.execute("UPDATE archivos SET ruta = ?, dir = ?, es_basura = CASE WHEN ? = 1 THEN 1 WHEN rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
con.execute("UPDATE archivos SET ruta = ?, dir = ?, "
"es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?",
(nueva, os.path.dirname(nueva), en_papelera, r["ruta"]))
# las carpetas siguen al material: se mueven de ruta, no se borran
viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'",
(de, esc_like(pref) + "%")).fetchall()
con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%"))
if os.path.isdir(a):
for v in viejas:
nueva = a if v["ruta"] == de else a + v["ruta"][len(de):]
fila = dict(v)
fila["ruta"] = nueva
fila["nombre"] = os.path.basename(nueva)
if en_papelera:
fila["es_basura"] = 1
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"],
fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"],
fila["es_proxy"], fila["es_basura"]))
con.close()