From b49288112343c81490c0ac1553a17f3b2f140e6b Mon Sep 17 00:00:00 2001 From: Labstream Date: Wed, 16 Sep 2026 10:49:36 -0500 Subject: [PATCH] =?UTF-8?q?1.8.1:=20fase=202=20de=20la=20auditor=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 12 +++++++ manifiesto.json | 20 ++++++------ organizador.py | 84 +++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 385f224..c8c9da4 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/manifiesto.json b/manifiesto.json index 7e6df59..601f0e5 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -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" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 63a0a49..d3d6245 100644 --- a/organizador.py +++ b/organizador.py @@ -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()