From b977fe1b3e3dae98dcbcc4e767d365428d6935ae Mon Sep 17 00:00:00 2001 From: Labstream Date: Wed, 16 Sep 2026 10:43:42 -0500 Subject: [PATCH] =?UTF-8?q?1.8.0:=20red=20de=20seguridad=20tras=20la=20aud?= =?UTF-8?q?itor=C3=ADa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 30 +++++ manifiesto.json | 20 ++-- organizador.py | 298 +++++++++++++++++++++++++++++++++++++--------- reglas.py | 15 ++- static/index.html | 32 +++-- 5 files changed, 315 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index c5e7918..385f224 100644 --- a/README.md +++ b/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: diff --git a/manifiesto.json b/manifiesto.json index 78172af..7e6df59 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -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" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 95e955d..63a0a49 100644 --- a/organizador.py +++ b/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: diff --git a/reglas.py b/reglas.py index 7cb9ab8..7a4442a 100644 --- a/reglas.py +++ b/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: diff --git a/static/index.html b/static/index.html index 9810a99..1af0e68 100644 --- a/static/index.html +++ b/static/index.html @@ -437,7 +437,7 @@

Historial

-

Cada vez que la app mueve algo lo apunta aquí, con ejemplos de qué fue a dónde. ?La app nunca borra: lo apartado en _Para borrar lo revisa y lo borra una persona desde el Explorador.

+

Cada vez que la app mueve algo lo apunta aquí, y Deshacer devuelve cada archivo a donde estaba. ?Si alguien movió un archivo después, ese se salta y se avisa. La app nunca borra: lo apartado en _Para borrar lo revisa y lo borra una persona desde el Explorador.

@@ -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 = '' + 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 = '' + 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 Calcular para ver, paso a paso, qué haría la app en esta carpeta.'; estadoPlan(); $('#optCards').innerHTML = ''; $('#anaEstado').textContent = ''; $('#convLista').innerHTML = 'Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.'; bloquear('btnEncolar', 'x'); $('#btnEncolar').textContent = 'Convertir'; $('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero'); $('#respCards').innerHTML = ''; $('#respTipos').innerHTML = 'calcula para verlo'; $('#respRawLista').innerHTML = 'calcula para verlo'; $('#respMuestra').innerHTML = 'calcula para verlo'; $('#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 => `
${esc(f)}
`).join('') + (d.frases.length > 3 ? `
Más en Revisar y ordenar.
` : ''); } const lotes = CARPETA.lotes || []; - $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `
${esc(fechaCorta(l.ts))}${esc(queSeHizo(l.lote))} · ${l.movimientos} mov.${l.errores ? ` · ${l.errores} avisos` : ''}
`).join('') : 'Todavía no se ha movido nada en esta carpeta.'; + $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `
${esc(fechaCorta(l.ts))}${esc(queSeHizo(l.lote))} · ${l.movimientos} mov.${l.errores ? ` · ${l.errores} avisos` : ''}${l.lote && l.lote.startsWith('optimizar') ? '' : ``}
`).join('') : 'Todavía no se ha movido nada en esta carpeta.'; + $$('#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 => `
${esc(a)}
`).join(''); + const avs = PLAN.avisos || []; + $('#planAvisos').innerHTML = avs.length ? `
${avs.length === 1 ? 'Revisa esto antes de aplicar' : 'Revisa estas ' + avs.length + ' cosas antes de aplicar'}:
    ${avs.map(a => `
  • ${esc(a)}
  • `).join('')}
` : ''; 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, `

Se mueven ${m.length} 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 ${esc(PLAN.raiz)}.

Nada se borra. Es un cambio de sitio dentro del mismo disco. Cierra Resolve y Premiere si tienen abierto material de esta carpeta para que no bloqueen archivos.

`, () => accion(btn, async () => { + modal(titulo, `

Se mueven ${m.length} 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 ${esc(PLAN.raiz)}.

Nada se borra y se puede deshacer desde el Historial. Es un cambio de sitio dentro del mismo disco. Cierra Resolve y Premiere si tienen abierto material de esta carpeta para que no bloqueen archivos.

`, () => 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', `
${esc(res.errores.join('\n'))}
`, 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 ? `
Avisos recientes
` + av.slice(0, 20).map(a => `
${hora(a.ts)}${esc(a.texto)}${!solo && a.raiz ? ` · ${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}` : ''}${a.detalle ? `
detalle
${esc(a.detalle)}
` : ''}
`).join('') : ''; - $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
${esc(titulo(k))}
` + dias[k].map(l => `
${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
ejemplos
${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
')}
` : ''}
`).join('')).join('') || '
Todavía no se ha movido nada.
'); + $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
${esc(titulo(k))}
` + dias[k].map(l => `
${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
ejemplos
${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
')}
` : ''}
${l.deshecho ? '' : ``}
`).join('')).join('') || '
Todavía no se ha movido nada.
'); + $$('#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…', `

«${esc(nombre)}»

`, 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…', `

El navegador solo entrega el nombre («${esc(nombre)}»). Buscando en los discos…

`, null); const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre));