From cea36ea0c561abdbc135576e69ec963cc27787db Mon Sep 17 00:00:00 2001 From: Labstream Date: Wed, 14 Oct 2026 06:55:38 -0500 Subject: [PATCH] =?UTF-8?q?3.7.0:=20auditor=C3=ADa=20de=20V=C3=ADa=20Sumap?= =?UTF-8?q?az:=20=C2=ABCalcular=C2=BB=20ya=20no=20se=20queda=20gris,=20ban?= =?UTF-8?q?co=20de=20material=20por=20temas,=20proxies=20sin=20original,?= =?UTF-8?q?=20y=20=C2=ABQu=C3=A9=20pesa=C2=BB=20por=20dentro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 51 +++++- asistente.py | 4 +- organizador.py | 438 ++++++++++++++++++++++++++++++++++++++++++-- prueba_funcional.py | 23 ++- prueba_orden.py | 61 ++++++ reglas.py | 21 ++- static/index.html | 140 +++++++++++--- 7 files changed, 694 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a07b065..173de01 100644 --- a/README.md +++ b/README.md @@ -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 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 -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) ``` @@ -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` 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 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 → diff --git a/asistente.py b/asistente.py index 2258a42..7ef6b0f 100644 --- a/asistente.py +++ b/asistente.py @@ -191,7 +191,9 @@ def filas_elegidas(plan_filas, dec): for f in plan_filas: c = f["cubeta"] 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) elif c in ("GRABACION", "CELULAR", "FOTOS"): # solo lo marcado: antes entraba todo el material aunque el plan lo diera por dudoso (el mes diff --git a/organizador.py b/organizador.py index fca5a13..385559c 100644 --- a/organizador.py +++ b/organizador.py @@ -42,7 +42,7 @@ import actualizador asistente.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__)) # 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_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.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)] 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.con = db() self.avisos = [] @@ -1076,8 +1113,14 @@ class Plan: return r 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, - marcado=None, kw_destinos=None): + marcado=None, kw_destinos=None, paso=None): destino = os.path.normpath(destino) 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» -> @@ -1101,7 +1144,8 @@ class Plan: # 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 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: aviso_colision = (f" · OJO: en el destino ya hay archivos con el mismo nombre " f"(«{choques[0]}»): esos no se moverían") @@ -1128,8 +1172,8 @@ class Plan: "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, # piezas), 3 el resto (exportados, proyectos, marca, música, dudosos) - "paso": "limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else - "fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto", + "paso": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else + "fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"), }) def suelto(self, fila, cubeta, destino_rel, motivo, confianza): @@ -1185,6 +1229,8 @@ class Plan: self.plan_auditoria() self.volcar_sueltos() self.plan_basura() + self.revisar_proxies() + self.proteger_bancos() self.con.close() # 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 @@ -1383,6 +1429,9 @@ class Plan: cub, motivo = R.cubeta_carpeta(nombre, self.dicc) if cub == "IGNORAR": return + if cub == "BANCO": + self.plan_banco(e) + return if cub == "CONTENEDOR": m_anio = re.search(r"(20\d\d)", nombre) if self.est.fechas_dentro(e.path): @@ -1488,6 +1537,153 @@ class Plan: return None, "" 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): """Una sesión de grabación: reparte sus archivos por fuente (y por mes si no hay mes de contexto).""" 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 carpetas = con.execute("SELECT * FROM carpetas WHERE raiz = ? AND (es_basura = 1 OR es_proxy = 1)", (self.raiz,)).fetchall() 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"])): - 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 - 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"]) + cub_set.add(c["ruta"].lower()) continue if any(R.norm(self.rel(c["ruta"])).startswith(R.norm(p)) for p in R.PAPELERAS): 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", "proxies: se regeneran" if cub == "PROXY" else "caché / autoguardado", "alta") 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() grupos = defaultdict(list) 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 if any(R.norm(self.rel(f["dir"])).startswith(R.norm(p)) for p in R.PAPELERAS): continue @@ -2329,7 +2531,7 @@ def vocabulario(raiz): apunta(h, p[1], "", "", "está numerada como una pieza, pero dentro guarda carpetas de varias fechas") continue # una pieza: no se toca por dentro cub, mot = R.cubeta_carpeta(nombre, dicc) - if cub == "IGNORAR": + if cub in ("IGNORAR", "BANCO"): apunta(h, nombre, cub, mot) continue if cub == "CONTENEDOR": @@ -2456,7 +2658,7 @@ def _sugerir(est, resumen, raiz, t): # c) por el nombre: se parece a una palabra del diccionario dicc = DICC["carpetas"] 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]}»" # d) por el contenido (manda si es contundente) if total[0] >= 3 and orden: @@ -4176,7 +4378,56 @@ def _mismas_tomas(candidatos, tope=400): return grupos +_DUP_CACHE = {} + + 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: 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 @@ -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", (os.path.normpath(raiz), minimo)).fetchall()] 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() por_nombre = {} for f in filas: @@ -4709,7 +4960,7 @@ def mapa_estructura(tipo, raiz=None): plantilla = pl.get(cub, "") n, b = hay.get(cub, [0, 0]) 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[""]), "ejemplos": ejemplos.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.", 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 --- ent = entrada(raiz) if ent["n"]: @@ -5827,7 +6090,7 @@ def acciones(raiz): 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.", 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") elif vieja: 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", 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 --- vac = carpetas_vacias(raiz) 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} +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): """El recorrido de una carpeta: qué paso está hecho, cuál toca y qué lo bloquea.""" raiz = _clave(ruta) @@ -6054,7 +6448,8 @@ def estado_carpeta(ruta): "diagnostico": diag, "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), - "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, "analisis": ANALISIS.get(raiz), "analizados": analizados["ok"] or 0, "sin_analizar": analizados["sin"] or 0, "cola_activa": COLA["activa"]}, @@ -6156,6 +6551,9 @@ class Manejador(BaseHTTPRequestHandler): return self._json(estado_carpeta(q["raiz"])) if ruta == "/api/mapa": 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": return self._json({**actualizador.ESTADO, "version": VERSION}) # ESTADO trae "version" vacía hasta la primera consulta if ruta == "/api/asistente/pasos": @@ -6343,6 +6741,18 @@ class Manejador(BaseHTTPRequestHandler): CONFIG["raices"] = d["raices"] guardar_json(CONFIG_PATH, CONFIG) 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": CONFIG["clientes"] = d["clientes"] guardar_json(CONFIG_PATH, CONFIG) diff --git a/prueba_funcional.py b/prueba_funcional.py index e26d654..2037ffc 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -192,6 +192,27 @@ def p_que_hay(): 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(): v = get('/api/vocabulario' + q(raiz=RAIZ)) 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), ('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), ('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), diff --git a/prueba_orden.py b/prueba_orden.py index a001b09..0e144f6 100644 --- a/prueba_orden.py +++ b/prueba_orden.py @@ -337,10 +337,71 @@ class Motor(unittest.TestCase): filas = [{"cubeta": "GRABACION", "marcado": False, "confianza": "baja"}, {"cubeta": "GRABACION", "marcado": True, "confianza": "media"}, {"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"}] elegidas = asistente.filas_elegidas(filas, {"carpetas": "seguras"}) 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): """22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»).""" raiz = cliente("Convertidos") diff --git a/reglas.py b/reglas.py index 7422392..8c754c8 100644 --- a/reglas.py +++ b/reglas.py @@ -451,6 +451,7 @@ CUBETAS = { "CELULAR": "Material de celular", "PROXY": "Proxies (se pueden regenerar)", "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", "REVISAR": "Revisar a mano", "IGNORAR": "Dejar como está", @@ -544,6 +545,21 @@ MARCA_SUB = { _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): """(cubeta, motivo) de una carpeta por su nombre; None si no se reconoce.""" if es_basura_dir(nombre): @@ -555,6 +571,8 @@ def cubeta_carpeta(nombre: str, diccionario: dict): n = norm(nombre) if n in diccionario: 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 # (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}»" palabras = n.split() 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 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 # propio Explorador de Windows, poniéndole a cada carpeta un icono de ese color. COLOR_CUBETA = { + "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", diff --git a/static/index.html b/static/index.html index 7c0c970..f8afd3b 100644 --- a/static/index.html +++ b/static/index.html @@ -145,6 +145,19 @@ .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 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 label{color:var(--mut);font-size:12px;display:flex;align-items:center;gap:6px} .toolbar .sep{flex:1} @@ -545,16 +558,16 @@

Revisar y ordenar

Todo se mueve dentro de la misma carpeta, y lo único que se borra son las carpetas que se quedan vacías, si las marcas tú. ?Basura y proxies van a _Para borrar. 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.

+
Esta carpeta todavía no está escaneada. Vuelve a Carpeta y pulsa Escanear (solo lee nombres y tamaños; no toca nada).