diff --git a/.github/workflows/publicar.yml b/.github/workflows/publicar.yml new file mode 100644 index 0000000..23dd804 --- /dev/null +++ b/.github/workflows/publicar.yml @@ -0,0 +1,92 @@ +# Compila, prueba y publica OrganiLAB desde un PC de Windows del estudio. +# +# Corre en un «runner» propio de GitHub instalado en ese PC (con la etiqueta «organilab»), no en las máquinas de +# GitHub: el NAS solo se alcanza desde la red del estudio, y la clave que firma las actualizaciones no sale de ese +# PC. Cómo prepararlo: README, sección «Compilar y publicar en el estudio». +# +# Se lanza de dos formas: +# - subiendo una etiqueta con las notas: git tag -a v3.8.0 -m "Lo que verán al actualizar" y git push origin v3.8.0 +# - a mano, en GitHub › Actions › «Publicar OrganiLAB» › Run workflow, escribiendo las notas. +name: Publicar OrganiLAB + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + notas: + description: 'Notas de la versión (lo que verán las copias instaladas al actualizar)' + required: true + +permissions: + contents: write + +concurrency: + group: publicar-organilab + cancel-in-progress: false + +jobs: + publicar: + runs-on: [self-hosted, windows, organilab] + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: La versión del código y la de la etiqueta coinciden + if: startsWith(github.ref, 'refs/tags/v') + shell: pwsh + run: | + $v = (Select-String -Path organizador.py -Pattern '^VERSION = "(.+)"').Matches[0].Groups[1].Value + if ("v$v" -ne $env:GITHUB_REF_NAME) { throw "organizador.py dice $v y la etiqueta es $env:GITHUB_REF_NAME" } + + - name: Pruebas + shell: pwsh + run: | + py -3.12 probar.py; if ($LASTEXITCODE) { exit 1 } + py -3.12 prueba_orden.py; if ($LASTEXITCODE) { exit 1 } + py -3.12 prueba_funcional.py; if ($LASTEXITCODE) { exit 1 } + + - name: labstream-os, al lado (el manifiesto se publica desde ahí) + shell: pwsh + run: | + if (-not (Test-Path ..\labstream-os)) { git clone https://github.com/Labstream2026/labstream-os.git ..\labstream-os } + git -C ..\labstream-os fetch -q origin main + + - name: Empaquetar + shell: pwsh + env: + NOTAS_A_MANO: ${{ inputs.notas }} + run: | + $n = $env:NOTAS_A_MANO + if (-not $n) { $n = (git tag -l --format='%(contents)' $env:GITHUB_REF_NAME) -join "`n" } + if (-not $n -or -not $n.Trim()) { throw "Faltan las notas: pon un mensaje en la etiqueta (git tag -a vX.Y.Z -m '...')" } + $env:NOTAS = $n.Trim() + py -3.12 empaquetar.py; if ($LASTEXITCODE) { exit 1 } + + - name: Probar el paquete como lo instalaría alguien + shell: pwsh + run: py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream; if ($LASTEXITCODE) { exit 1 } + + - name: Publicar (NAS y manifiesto) + shell: bash + run: bash publicar.sh + + - name: Comprobar lo publicado + shell: pwsh + run: py -3.12 verificar_publicado.py; if ($LASTEXITCODE) { exit 1 } + + - name: Guardar el manifiesto en el repositorio + shell: pwsh + run: | + git -C ..\labstream-os checkout -- public/herramientas/organizador.json + $v = (Select-String -Path organizador.py -Pattern '^VERSION = "(.+)"').Matches[0].Groups[1].Value + Copy-Item manifiesto.json $env:RUNNER_TEMP\manifiesto.json + git fetch -q origin main + git checkout -q -B main origin/main + Copy-Item $env:RUNNER_TEMP\manifiesto.json manifiesto.json + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add manifiesto.json + git diff --cached --quiet; if ($LASTEXITCODE) { git commit -q -m "Manifiesto de la $v publicada"; git push -q origin main } diff --git a/README.md b/README.md index 173de01..2919bea 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,37 @@ y nunca pisa un MP4 que ya exista; las raíces de unidad (`N:\`) ya se consultan bash publicar.sh # sube los zips al NAS, deja el manifiesto en GitHub y despliega el OS `empaquetar.py` no deja salir un paquete con un binario sin firmar ni uno cuyo motor no arranque. Con -`SOLO_LOCAL=si` construye sin tocar el manifiesto del OS (para ensayar). +`SOLO_LOCAL=si` construye sin tocar el manifiesto del OS (para ensayar). `verificar_publicado.py` comprueba +después lo que sirve el OS (versión, firma, que es el mismo manifiesto y la huella del paquete). + +### Compilar y publicar en el estudio + +`.github/workflows/publicar.yml` hace todo lo de arriba solo, en un PC de Windows del estudio registrado como +«runner» propio de GitHub. No en las máquinas de GitHub: el NAS solo se alcanza desde la red del estudio, y la +clave que firma las actualizaciones no tiene que salir de ese PC (quien la tenga puede mandar una actualización a +todos los OrganiLAB instalados). + +Preparar el PC una vez (con la sesión del usuario que publicará): + +1. Python 3.12 (`py -3.12`), Git para Windows (trae Git Bash, que usa `publicar.sh`) y ffmpeg (el «full build» + de Gyan; si no está en la ruta de WinGet, la variable de entorno `FFMPEG_DIR` apunta a su carpeta `bin`). +2. La clave de firma en `%USERPROFILE%\.labstream\organizador-firma.json` (copiada desde el PC donde está hoy, y + con copia de seguridad aparte), y la llave del NAS en `%USERPROFILE%\.ssh\editorias_nas`. +3. Permiso de escritura en GitHub para `labstream-organizador` y `labstream-os` (Git Credential Manager, con la + cuenta de Labstream). +4. En GitHub › `labstream-organizador` › Settings › Actions › Runners › «New self-hosted runner» › Windows: seguir + los pasos, y al configurarlo añadir la etiqueta `organilab`. Instalarlo como servicio con ese mismo usuario + (para que vea la clave y las llaves), en un PC que esté encendido. + +Publicar una versión: subir `VERSION` en `organizador.py`, y + + git tag -a v3.8.0 -m "Lo que verán las copias instaladas al actualizar" + git push origin v3.8.0 + +o en GitHub › Actions › «Publicar OrganiLAB» › «Run workflow», escribiendo las notas. El trabajo comprueba que la +etiqueta y `VERSION` coinciden, pasa las cuatro baterías de pruebas, empaqueta, prueba el paquete arrancándolo, +publica, comprueba lo publicado y guarda `manifiesto.json` en `main`. Si una prueba falla, se detiene ahí y no +se publica nada. Cuando el OS sirve el manifiesto nuevo, cada copia instalada lo ve al abrirse (o al pulsar «Actualizar ahora»), descarga el zip «app» (15 MB), comprueba el sha256 y se sustituye sola sin tocar `datos\` ni `ffmpeg\`. @@ -880,6 +910,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.8.0, lo que faltaba. Munka, después de la 3.7.0: «al hacer un cambio en el plan me debería dar un chequeo +para saber que ya se hizo», «construye las mejoras que planteas» y, aplicando el plan de Vía Sumapaz, 753 +movimientos que no se pudieron y «database is locked» al buscar clips para convertir («lo más importante»). + +- **«database is locked» al buscar clips.** Después de «Aplicar» en una carpeta grande, el inventario se ponía + al día con la base tomada durante más de diez minutos (en Sumapaz, 2.493 movimientos): preguntaba al disco de + red dos veces por movimiento, a 41 ms con el NAS ocupado, y buscaba lo de dentro de cada carpeta recorriendo + la tabla entera. Mientras tanto «Buscar clips» esperaba un minuto y se rendía. Ahora lo que hay que preguntar + al disco se pregunta antes, todo a la vez y en paralelo, y lo de dentro de cada carpeta se busca por índice: + 1,3 segundos para los 2.493 movimientos, con las mismas filas de archivos que la versión anterior (comparado + tabla por tabla) y 183 carpetas que la anterior perdía del inventario y sí existen en el disco. Y la lectura + de clips, si la base está ocupada, espera y reintenta. Leídos así los clips de Sumapaz: 2.760, 2.319 + convertibles (1,97 TB, unos 0,33 TB convertidos, 145 de ellos BRAW). +- **Los 753 que no se movieron**: cada evento de «Fotos varias» guarda la misma foto en «Alta» y en «Baja» con + el mismo nombre, y el plan juntaba las dos en una sola carpeta; la segunda chocaba con la primera y, como la + app nunca renombra, se quedaba donde estaba (no se perdió nada). Ahora las subcarpetas con sentido bajo una + sesión («Alta», «Baja», «Seleccionadas»…) se conservan debajo de la cámara, el plan avisa antes si dos archivos + de una misma fila acabarían en el mismo sitio, y «Fiesta 6 Años» ya no pierde el 6 (un número entre dos + palabras es parte del nombre; los prefijos y las fechas se siguen quitando). +- **Los fallos de «Aplicar», explicados**: se agrupan por motivo (ya había un archivo con ese nombre, estaba + abierto en otro programa, sin permiso, ya no estaba, la copia entre discos no quedó completa, se cortó la red) + y cada grupo dice qué hacer, con ejemplos. +- **El banco de material, en llano**: cada fila dice «69 clips de la URSA (BRAW) en el tema «Lanzamiento + Sumapaz» → se quedan en el mismo tema, en 2023-12-23 › URSA_BRAW», en vez de dos rutas largas; arriba, qué + hace el paso con un ejemplo, y qué quiere decir «probable» y «dudoso». +- **Inicio, como lista con ✓**: lo hecho se queda en su sitio con «Hecho hoy a las 07:41: …» en vez de + desaparecer (el tipo de trabajo, el stock, los repetidos, la bandeja, convertir), arriba se cuentan los + hechos, y lo que acaba de pasar a hecho se ilumina. «Basura y proxies» deja de pedirse cuando lo que queda es + lo de «No tocar» o lo que va sin marcar. +- **Fechas raras** (Revisar): las tandas de clips de una cámara con el reloj sin poner (2008, 2009…), con una + pista de su fecha real; se pone la del primer clip y la app corre los demás lo mismo. La corrección se guarda + por archivo (nombre y peso), sobrevive a volver a escanear y a que el plan los mueva, y se deshace. +- **Escaneo profundo** (por defecto; se apaga en Ajustes): después de escanear, la app lee cada clip (códec, + duración, resolución) en segundo plano y solo lo nuevo. +- **Buscar en todos**: el buscador de la barra lateral encuentra un archivo o una carpeta por su nombre en todas + las carpetas de clientes, con o sin tildes, y abre su carpeta. +- **Otros discos**: si el cliente está también en otros discos (Vía Sumapaz: K:, Q: y dos en T:), Inicio lo + dice y deja traer cada sitio con copia comprobada; el original no se toca. +- **El banco, por temas o por fecha**: en «Qué va a pasar» se elige; por fecha, cada grabación va a la + 00_Grabacion de su mes con su tema de etiqueta. +- **«Calcular de nuevo» más rápido**: las comprobaciones del plan contra el disco van al final y en paralelo; en + Sumapaz después de aplicar, de 58 s a 20 s. +- **Compilar en el estudio**: `.github/workflows/publicar.yml` y la guía de arriba; las pruebas devuelven error + si algo falla, y `verificar_publicado.py` está en el repositorio. + +Pruebas: `prueba_orden.py` 38 casos (cuatro nuevos: «Alta» y «Baja», el reloj de una cámara y buscar, el banco +por fecha, otros discos); `prueba_funcional.py` 33 de 33 (buscar y «Fechas raras» por la interfaz 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 diff --git a/empaquetar.py b/empaquetar.py index 361d88d..6d96f4a 100644 --- a/empaquetar.py +++ b/empaquetar.py @@ -46,7 +46,7 @@ MOTOR = f"python{sys.version_info.major}{sys.version_info.minor}" # python SIN_LIB = {"site-packages", "test", "idlelib", "tkinter", "turtledemo", "ensurepip", "venv", "lib2to3", "pydoc_data", "__pycache__"} SIN_DLL = ("_tkinter", "tcl", "tk8", "zlib1", "_test", "_ctypes_test", "xxlimited") -ffbin = sys.argv[1] if len(sys.argv) > 1 else "" +ffbin = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("FFMPEG_DIR", "") # en el PC compilador: FFMPEG_DIR if not ffbin: for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")): ffbin = c diff --git a/organizador.py b/organizador.py index 0368223..91293e8 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.7.1" +VERSION = "3.8.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 @@ -655,6 +655,7 @@ def escanear(ruta): if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2: con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", (pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0])) + _aplicar_relojes(con, raiz) # las fechas corregidas a mano siguen corregidas cfilas = [] for d, info in dirs.items(): n, b, fechas, p, bs, nombre = info @@ -786,10 +787,218 @@ def lanzar_escaneo(ruta): p = PROGRESO.get(raiz) if p and p["estado"] == "corriendo": return False - threading.Thread(target=escanear, args=(raiz,), daemon=True).start() + threading.Thread(target=_escanear_y_leer, args=(raiz,), daemon=True).start() return True +def _escanear_y_leer(raiz): + """El escaneo y, si está el escaneo profundo (por defecto sí), después la lectura de cada clip: códec, + duración y resolución, en segundo plano y solo de lo que no se había leído. El escaneo solo mira nombres, + tamaños y fechas; sin leer los clips no se sabe si dos que se llaman igual son el mismo con otra calidad + (2.387 «por comparar» en Vía Sumapaz) ni cuáles conviene convertir.""" + escanear(raiz) + ajuste = CONFIG.get("escaneo") or {} + if (PROGRESO.get(raiz) or {}).get("estado") != "listo" or not ajuste.get("profundo", True): + return + a = ANALISIS.get(raiz) + if a and a.get("estado") == "corriendo": + return + analizar(raiz, int(ajuste.get("min_mb", 5))) + + +# ---- el reloj de una cámara +# Una cámara con el reloj sin poner fecha sus clips en 2008 o 2009 (medido en Vía Sumapaz: 250 clips de la +# Blackmagic «del 28 de julio de 2008»). La app lo detecta; aquí se corrige: se dice la fecha real del primer +# clip de esa tanda y los demás se corren lo mismo. La corrección se guarda por archivo (nombre y peso), así +# sobrevive a volver a escanear y a que el plan mueva los clips de carpeta. +_ROLES_RELOJ = ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "foto_camara") +_ANIO_RELOJ = "2014" + + +def _tabla_relojes(con): + con.execute("CREATE TABLE IF NOT EXISTS relojes (raiz TEXT, nombre TEXT, bytes INTEGER, dias INTEGER, grupo TEXT, " + "dir TEXT, fuente TEXT, ts REAL)") + + +def _aplicar_relojes(con, raiz): + """Vuelve a aplicar las correcciones de reloj de esta carpeta (después de escanear, la fecha vuelve a ser la + de la cámara).""" + _tabla_relojes(con) + con.execute("UPDATE archivos SET fecha = date(substr(fecha, 1, 10), (SELECT r.dias || ' days' FROM relojes r " + "WHERE r.raiz = archivos.raiz AND r.nombre = archivos.nombre AND r.bytes = archivos.bytes LIMIT 1)), " + "fecha_origen = 'corregida' WHERE raiz = ? AND fecha_origen != 'corregida' AND EXISTS (SELECT 1 FROM " + "relojes r WHERE r.raiz = archivos.raiz AND r.nombre = archivos.nombre AND r.bytes = archivos.bytes)", (raiz,)) + + +def _nombres_fuente(): + out = {} + for k, _, d in R.FIRMAS: + out.setdefault(k, d) + return out + + +def relojes(raiz): + """Las tandas de clips con fecha imposible, con una pista de su fecha real; y las ya corregidas.""" + raiz = _clave(raiz) + con = db() + _tabla_relojes(con) + roles = ",".join("?" * len(_ROLES_RELOJ)) + grupos = [dict(x) for x in con.execute( + f"SELECT dir, fuente, COUNT(*) n, SUM(bytes) bytes, MIN(substr(fecha, 1, 10)) desde, MAX(substr(fecha, 1, 10)) hasta, " + f"MIN(nombre) ejemplo FROM archivos WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({roles}) " + f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ? GROUP BY dir, fuente " + f"ORDER BY SUM(bytes) DESC", (raiz,) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchall()] + nf = _nombres_fuente() + for g in grupos: + g["rel"] = os.path.relpath(g["dir"], raiz) + g["camara"] = nf.get(g["fuente"], g["fuente"] or "") + # la pista: la fecha de otros clips de esa misma carpeta que sí la traen bien, o el año que diga la ruta + bien = [x[0] for x in con.execute( + f"SELECT substr(fecha, 1, 10) FROM archivos WHERE raiz = ? AND dir = ? AND es_basura = 0 AND rol IN ({roles}, 'celular_video') " + f"AND (fecha_origen IN ('nombre', 'corregida') OR substr(fecha, 1, 4) >= ?) ORDER BY fecha", + (raiz, g["dir"]) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchall()] + sug, por_que = "", "" + if bien: + sug = bien[len(bien) // 2] + por_que = (f"es la fecha de otro clip de esa misma carpeta que sí la trae bien" if len(bien) == 1 else + f"es la fecha de otros {len(bien):,} clips de esa misma carpeta que sí la traen bien".replace(",", ".")) + else: + anios = [a for a in (R.detectar_anio(s) for s in g["rel"].split(os.sep)) if a] + if anios: + sug = f"{anios[-1]}-{g['desde'][5:]}" + por_que = f"la carpeta dice {anios[-1]}; el mes y el día son los de la cámara" + g.update(sugerencia=sug, por_que=por_que) + hechos = [dict(x) for x in con.execute( + "SELECT grupo, dir, fuente, COUNT(*) n, MAX(dias) dias, MAX(ts) ts FROM relojes WHERE raiz = ? GROUP BY grupo ORDER BY ts DESC", + (raiz,)).fetchall()] + for h in hechos: + h["rel"] = os.path.relpath(h["dir"], raiz) if h["dir"] else "" + h["camara"] = nf.get(h["fuente"], h["fuente"] or "") + con.close() + return {"grupos": grupos, "corregidos": hechos} + + +def corregir_reloj(raiz, dir_, fuente, fecha_real): + raiz = _clave(raiz) + try: + real = datetime.date.fromisoformat((fecha_real or "")[:10]) + except ValueError: + return {"error": "escribe la fecha real del primer clip"} + if real > datetime.date.today(): + return {"error": "esa fecha todavía no ha llegado"} + con = db() + _tabla_relojes(con) + cond = (f"raiz = ? AND dir = ? AND fuente = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({','.join('?' * len(_ROLES_RELOJ))}) " + f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ?") + args = (raiz, os.path.normpath(dir_ or ""), fuente or "") + _ROLES_RELOJ + (_ANIO_RELOJ,) + filas = con.execute(f"SELECT nombre, bytes, substr(fecha, 1, 10) f FROM archivos WHERE {cond}", args).fetchall() + if not filas: + con.close() + return {"error": "ahí ya no queda nada que corregir"} + desde = min(x["f"] for x in filas) + dias = (real - datetime.date.fromisoformat(desde)).days + grupo = f"{time.time():.3f}" + with con: + con.executemany("INSERT INTO relojes VALUES (?,?,?,?,?,?,?,?)", + [(raiz, x["nombre"], x["bytes"], dias, grupo, os.path.normpath(dir_), fuente or "", time.time()) for x in filas]) + _aplicar_relojes(con, raiz) + con.close() + bitacora({"op": "reloj", "raiz": raiz, "dir": dir_, "fuente": fuente, "dias": dias, "n": len(filas)}) + olvidar_plan(raiz) + return {"ok": True, "n": len(filas), "dias": dias, "desde": desde, "a": real.isoformat()} + + +def deshacer_reloj(raiz, grupo): + raiz = _clave(raiz) + con = db() + _tabla_relojes(con) + filas = con.execute("SELECT nombre, bytes, dias FROM relojes WHERE raiz = ? AND grupo = ?", (raiz, grupo)).fetchall() + if not filas: + con.close() + return {"error": "esa corrección ya no está"} + with con: + for x in filas: + con.execute("UPDATE archivos SET fecha = date(substr(fecha, 1, 10), ?), fecha_origen = 'mtime' WHERE raiz = ? AND " + "nombre = ? AND bytes = ? AND fecha_origen = 'corregida'", (f"{-x['dias']} days", raiz, x["nombre"], x["bytes"])) + con.execute("DELETE FROM relojes WHERE raiz = ? AND grupo = ?", (raiz, grupo)) + con.close() + olvidar_plan(raiz) + return {"ok": True, "n": len(filas)} + + +# ---- buscar en todos los clientes +_SIN_TILDES = str.maketrans("áéíóúüñàèìòùÁÉÍÓÚÜÑÀÈÌÒÙ", "aeiouunaeiouAEIOUUNAEIOU") + + +def _llano(t): + return (t or "").translate(_SIN_TILDES).lower() + + +def buscar(texto, limite=200): + """Un archivo o una carpeta por su nombre, en todas las carpetas de clientes escaneadas. Antes solo había + «¿Dónde va a parar este archivo?», que dice dónde iría, no dónde está.""" + pals = [p for p in _llano(texto).replace("\\", " ").replace("/", " ").split() if p] + if not pals or max(len(p) for p in pals) < 2: + return {"error": "escribe al menos dos letras"} + clientes = {_clave(r["ruta"]).lower(): r["cliente"] for r in CONFIG["raices"]} + con = db() + # un primer filtro en la base (sin tildes no se puede: SQLite solo pasa a minúsculas lo que no lleva tilde) + ancla = max(pals, key=len) + filas = con.execute("SELECT ruta, raiz, dir, nombre, bytes, fecha, rol, fuente, es_basura, es_proxy FROM archivos " + "WHERE lower(ruta) LIKE ? ESCAPE '!' OR ruta LIKE ? ESCAPE '!'", + ("%" + esc_like(ancla) + "%", "%" + esc_like(ancla.upper()) + "%")).fetchall() \ + if ancla.isascii() else con.execute("SELECT ruta, raiz, dir, nombre, bytes, fecha, rol, fuente, es_basura, es_proxy FROM archivos").fetchall() + con.close() + hay = [] + for f in filas: + r = _llano(f["ruta"]) + if all(p in r for p in pals): + en_nombre = all(p in _llano(f["nombre"]) for p in pals) + hay.append((0 if en_nombre else 1, -(f["bytes"] or 0), f)) + hay.sort(key=lambda x: (x[0], x[1])) + out = [] + for _, _, f in hay[:limite]: + out.append({"ruta": f["ruta"], "dir": f["dir"], "nombre": f["nombre"], "bytes": f["bytes"], "fecha": (f["fecha"] or "")[:10], + "rol": R.ROL_QUE.get(f["rol"], f["rol"]), "raiz": f["raiz"], "cliente": clientes.get((f["raiz"] or "").lower(), f["raiz"]), + "rel": os.path.relpath(f["dir"], f["raiz"]) if f["raiz"] else f["dir"], + "apartado": bool(f["es_basura"] or f["es_proxy"])}) + return {"total": len(hay), "resultados": out, "carpetas": len(clientes)} + + +# ---- el mismo cliente en otros discos +_DISCOS_REFRESCO = {"activo": False} + + +def _refrescar_discos(): + try: + discos() + finally: + _DISCOS_REFRESCO["activo"] = False + + +def otros_sitios(raiz): + """Dónde más está el cliente de esta carpeta, según los discos conectados. No espera: usa lo último que se + miró y, si es viejo, lo vuelve a mirar en segundo plano (mirar todos los discos de red tarda).""" + r = raiz_cfg(raiz) + if not r: + return [] + if time.time() - (_DISCOS_CACHE.get("ts") or 0) > 120 and not _DISCOS_REFRESCO["activo"]: + _DISCOS_REFRESCO["activo"] = True + threading.Thread(target=_refrescar_discos, daemon=True).start() + d = _DISCOS_CACHE.get("datos") + if not d: + return [] + registradas = [_clave(x["ruta"]).lower() for x in CONFIG["raices"]] + out = [] + for ruta in d["clientes"].get(r["cliente"], []): + rl = os.path.normpath(ruta).lower() + if any(rl == x or rl.startswith(x + os.sep) or x.startswith(rl + os.sep) for x in registradas): + continue + disco = next((x for x in d["discos"] if x["letra"].lower() == ruta[:2].lower()), {}) + out.append({"ruta": ruta, "unc": disco.get("unc", ""), "disco": disco.get("nombre", "")}) + return out + + # ========================================================================== # Utilidades de fechas / nombres para el plan # ========================================================================== @@ -1049,6 +1258,7 @@ class Plan: 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.bancos_info = [] # lo mismo para la interfaz: nombre, si dice «No tocar», y cómo se ordena 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 @@ -1137,24 +1347,16 @@ class Plan: fila = self.con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.normpath(origen),)).fetchone() n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0) aviso_colision = "" + # Lo que hay que preguntarle al disco (¿ya hay en el destino un archivo con ese nombre?, ¿la carpeta + # destino existe?) se apunta y se pregunta al final, todo a la vez y en paralelo (`comprobar_destinos`). + # Medido el 14-oct-2026 en Vía Sumapaz, después de aplicar: 1.362 preguntas una detrás de otra, a 24-46 ms + # cada una con el NAS ocupado; 43 de los 58 segundos que tardaba «Calcular de nuevo». + chk = None if archivos is not None and accion != "renombrar": - # también para los grupos de archivos: si el destino ya tiene un archivo con ese nombre, - # mover chocaría. Antes solo se comprobaba en las filas de carpeta. - especiales = kw_destinos or {} - # solo los primeros: cada comprobación es una consulta al disco de red, y para avisar - # basta con encontrar uno - choques = [os.path.basename(a[0]) for a in archivos[:40] - if 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") - confianza, marcado = "baja", False - elif archivos is None and accion != "renombrar" and os.path.isfile(destino): - aviso_colision = " · ya existe un archivo con ese nombre en el destino: no se moverá" - confianza, marcado = "baja", False - elif archivos is None and not solo_mayusculas and os.path.isdir(destino) and os.path.isdir(origen): - aviso_colision = " · la carpeta destino ya existe: se fusionan" + # solo los primeros archivos de cada fila: para avisar basta con encontrar uno + chk = {"tipo": "archivos", "primeros": [a[0] for a in archivos[:40]], "kw": dict(kw_destinos or {})} + elif archivos is None and accion != "renombrar": + chk = {"tipo": "carpeta", "fusion": not solo_mayusculas} self.filas.append({ "id": len(self.filas) + 1, "origen": origen, @@ -1174,6 +1376,7 @@ class Plan: # piezas), 3 el resto (exportados, proyectos, marca, música, dudosos) "paso": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else "fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"), + "_chk": chk, }) def suelto(self, fila, cubeta, destino_rel, motivo, confianza): @@ -1229,6 +1432,7 @@ class Plan: self.plan_auditoria() self.volcar_sueltos() self.plan_basura() + self.comprobar_destinos() self.revisar_proxies() self.proteger_bancos() self.con.close() @@ -1238,7 +1442,8 @@ class Plan: self.marcar_colisiones() for i, f in enumerate(self.filas, 1): f["id"] = i - return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos} + return {"raiz": self.raiz, "cliente": self.cliente, "tipo": self.tipo, "filas": self.filas, "avisos": self.avisos, + "bancos": self.bancos_info} def marcar_colisiones(self): """Dos movimientos del mismo plan que acaban en el mismo archivo. La comprobación de `agregar` @@ -1256,6 +1461,9 @@ class Plan: if otra is not None and otra is not f: self.avisar_colision(f, otra, os.path.basename(d)) break + if otra is f: + self.avisar_repetido(f, os.path.basename(d)) + break destinos.setdefault(d, f) elif f["accion"] == "renombrar": d = f["destino"].lower() @@ -1265,6 +1473,16 @@ class Plan: else: carpetas[d] = f + def avisar_repetido(self, fila, que): + aviso = f" · OJO: dentro de «{fila['origen_rel']}» hay dos archivos «{que}» que acabarían en la misma carpeta" + if aviso not in fila["motivo"]: + fila["motivo"] += aviso + fila["confianza"] = "baja" + fila["marcado"] = False + self.avisos.append(f"En «{fila['origen_rel']}» hay dos archivos que se llaman «{que}» (por ejemplo, la misma foto " + f"en dos tamaños) y los dos irían a «{fila['destino_rel']}». Uno no cabría: la fila queda sin " + f"marcar para que lo mires antes.") + def avisar_colision(self, fila, otra, que): aviso = f" · OJO: «{otra['origen_rel']}» también va a «{fila['destino_rel']}» y las dos traen «{que}»" if aviso not in fila["motivo"]: @@ -1549,6 +1767,9 @@ class Plan: 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)) + # cómo se ordena: por temas (lo de siempre) o por fecha, a la 00_Grabacion de su mes con el tema de etiqueta + modo = ((CONFIG.get("bancos") or {}).get(self.raiz.lower()) or {}).get(R.norm(e.name), "temas") + self.bancos_info.append({"nombre": e.name, "protegido": protegido, "modo": modo}) 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"]: @@ -1563,20 +1784,63 @@ class Plan: 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 [])) + reloj = f["fecha_origen"] not in ("nombre", "corregida") and int(f["fecha"][:4]) < 2014 + tema = segs[-1] if segs else e.name + if modo == "fecha": + ay, am = int(f["fecha"][:4]), int(f["fecha"][5:7]) + sub = os.path.join(*[x for x in (limpiar_nombre(tema), fuente, R.DUPLICADOS if repetido else "") if x]) + destino = self.abs_(destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=self.dia_de(f["fecha"], ay, am), + sub=sub, nombre=limpiar_nombre(tema))) + else: + 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" + (f"banco de material «{e.name}», por fecha: a la grabación de su mes, con «{tema}» de etiqueta" if modo == "fecha" else + 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 + self.filas[-1]["banco"] = e.name + + def comprobar_destinos(self): + """Las comprobaciones contra el disco de todas las filas, a la vez: primero qué carpetas existen y luego, + solo en las que existen, qué archivos de destino ya están. Lo que choca queda sin marcar y dudoso.""" + pend, dirs = [], set() + for f in self.filas: + chk = f.pop("_chk", None) + if not chk: + continue + if chk["tipo"] == "archivos": + esp = dict(f.get("destinos_archivo") or {}) + esp.update(chk["kw"]) + dests = [esp.get(a) or os.path.join(f["destino"], os.path.basename(a)) for a in chk["primeros"]] + dirs.update(os.path.dirname(d) for d in dests) + pend.append((f, "archivos", dests)) + else: + dirs.update((f["destino"], f["origen"])) + pend.append((f, "carpeta", chk["fusion"])) + if not pend: + return + hay_dir = _existen(dirs) + candidatos = {d for f, t, x in pend if t == "archivos" for d in x if hay_dir.get(os.path.dirname(d))} + candidatos |= {f["destino"] for f, t, x in pend if t == "carpeta" and not hay_dir.get(f["destino"])} + hay_archivo = _son_archivos(candidatos) + for f, t, x in pend: + if t == "archivos": + choques = [os.path.basename(d) for d in x if hay_archivo.get(d)] + if choques: + f["motivo"] += f" · OJO: en el destino ya hay archivos con el mismo nombre («{choques[0]}»): esos no se moverían" + f["confianza"], f["marcado"] = "baja", False + elif hay_archivo.get(f["destino"]): + f["motivo"] += " · ya existe un archivo con ese nombre en el destino: no se moverá" + f["confianza"], f["marcado"] = "baja", False + elif x and hay_dir.get(f["destino"]) and hay_dir.get(f["origen"]): + f["motivo"] += " · la carpeta destino ya existe: se fusionan" 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, @@ -1729,6 +1993,7 @@ class Plan: # lo que digan las carpetas intermedias: el año o el mes («Blackmagic (BRAW)/2023/…»), el # nombre de la sesión («Grabaciones/Tomas consultorio/…») y si ya estaba apartado por repetido anio_ruta, mes_ruta, etiqueta, repetido = None, None, etiqueta_base, False + resto = [] # subcarpetas con sentido debajo de la sesión: se conservan debajo de la cámara segs = [] if os.path.normcase(os.path.normpath(f["dir"])) == aqui else os.path.relpath(f["dir"], e.path).split(os.sep) for seg in segs: if R.detectar_anio(seg): @@ -1739,6 +2004,11 @@ class Plan: repetido = True elif not etiqueta and R.norm(seg) not in R.DICCIONARIO_SEMILLA and not R.es_carpeta_de_tarjeta(seg): etiqueta = R.etiqueta_grabacion(seg) + elif not _solo_fuente(seg) and R.norm(seg) not in R.DICCIONARIO_SEMILLA: + # Medido el 14-oct-2026 en Vía Sumapaz: cada evento de «Fotos varias» guarda la misma foto + # en «Alta» y en «Baja», con el mismo nombre. Juntarlas en una sola carpeta hacía chocar + # una con otra: 753 fotos no se movieron. Lo que no dice cámara, tarjeta ni día se conserva. + resto.append(limpiar_nombre(seg)) fa, fm = int(f["fecha"][:4]), int(f["fecha"][5:7]) por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado» if anio and mes: @@ -1772,7 +2042,7 @@ class Plan: hoy = datetime.date.today() if (ay, am) > (hoy.year, hoy.month): ay -= 1 - grupos[(ay, am, self.dia_de(f["fecha"], ay, am), etiqueta, fuente, repetido, fconf)].append((f["ruta"], f["bytes"])) + grupos[(ay, am, self.dia_de(f["fecha"], ay, am), etiqueta, fuente, tuple(resto), repetido, fconf)].append((f["ruta"], f["bytes"])) aviso_fecha = "" # hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar # toda la carpeta sin marcar. @@ -1784,8 +2054,8 @@ class Plan: self.avisos.append(f"«{self.rel(e.path)}» está dentro de {R.nombre_mes(mes)} de {anio}, pero {cuantos} de " f"sus archivos se grabaron en {R.nombre_mes(dm)} de {da}. Se archivan en el mes en que " f"se grabaron, no en el que dice la carpeta.") - for (ay, am, dia, etiqueta, fuente, repetido, fconf), archivos in grupos.items(): - sub = os.path.join(*[x for x in (etiqueta, fuente, R.DUPLICADOS if repetido else "") if x]) + for (ay, am, dia, etiqueta, fuente, resto, repetido, fconf), archivos in grupos.items(): + sub = os.path.join(*[x for x in (etiqueta, fuente, *resto, R.DUPLICADOS if repetido else "") if x]) destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=dia, sub=sub, nombre=limpiar_nombre(nombre)) # si todos ya están en esa carpeta, no hay nada que hacer @@ -2033,6 +2303,21 @@ def nombre_libre(destino): return f"{base} ({i}){ext}" +def _renombrar(origen, destino, intentos=4): + """os.rename, esperando un momento si el archivo lo tiene abierto un instante otro programa: la lectura de + clips del escaneo profundo, el antivirus o la vista previa del Explorador. Windows no deja mover un archivo + abierto (error 32), y antes eso contaba como fallo aunque al segundo siguiente ya estuviera libre. Lo que + sigue abierto de verdad (un proyecto de Resolve) falla igual, tras menos de dos segundos.""" + for i in range(intentos): + try: + os.rename(origen, destino) + return + except OSError as ex: + if getattr(ex, "winerror", None) not in (32, 33) or i == intentos - 1: + raise + time.sleep(0.3 * (i + 1)) + + def mover(origen, destino, lote, movidos): """mueve archivo o carpeta; si la carpeta destino existe, fusiona""" origen = os.path.normpath(origen) @@ -2044,7 +2329,7 @@ def mover(origen, destino, lote, movidos): # que isdir/exists del destino dicen que sí y el camino normal lo tomaría por una fusión de una # carpeta consigo misma. Antes se devolvía sin hacer nada y el plan prometía un renombrado que # nunca ocurría: os.rename sí sabe hacerlo en NTFS. - os.rename(origen, destino) + _renombrar(origen, destino) bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino}) movidos.append((origen, destino)) return @@ -2075,7 +2360,7 @@ def mover(origen, destino, lote, movidos): raise FileExistsError(f"ya existe «{os.path.basename(destino)}» en el destino; no se mueve para no renombrarlo") destino = nombre_libre(destino) try: - os.rename(origen, destino) + _renombrar(origen, destino) except OSError: # entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen if os.path.isdir(origen): @@ -2101,11 +2386,37 @@ APLICANDO = {"hechas": 0, "total": 0, "actual": "", "archivo": "", "unidades": 0 "movidos": 0, "fallos": 0, "lote": "", "inicio": 0, "fin": 0} +def _motivo_fallo(ex): + """Por qué no se pudo mover algo, en una palabra: para agrupar los fallos y decir qué hacer con cada clase.""" + t = str(ex).lower() + w = getattr(ex, "winerror", None) + if isinstance(ex, FileExistsError) or "ya existe" in t: + return "existe" + if "en uso" in t or w in (32, 33): + return "en_uso" + if isinstance(ex, PermissionError) or w == 5 or "acceso denegado" in t: + return "permiso" + if isinstance(ex, FileNotFoundError) or "ya no está" in t or w in (2, 3): + return "no_esta" + if "incompleta" in t: + return "copia" + if w in (53, 59, 64, 67, 1231) or "red" in t: + return "red" + return "otro" + + def aplicar(raiz, filas): lote = time.strftime("%Y%m%d-%H%M%S") movidos = [] errores = [] + motivos = {} # motivo -> {"n", "ejemplos"}: los fallos agrupados, para explicarlos hechas = 0 + + def fallo(ex, que): + m = motivos.setdefault(_motivo_fallo(ex), {"n": 0, "ejemplos": []}) + m["n"] += 1 + if len(m["ejemplos"]) < 8: + m["ejemplos"].append(que) filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower())) unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas) fallos = 0 @@ -2133,6 +2444,7 @@ def aplicar(raiz, filas): 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 + fallo(ex, os.path.relpath(a, raiz)) if fallos_fila <= 5: errores.append(f"{os.path.basename(a)}: {ex}") APLICANDO.update(movidos=len(movidos), fallos=fallos + fallos_fila) @@ -2149,9 +2461,11 @@ def aplicar(raiz, filas): else: fallos += 1 errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)") + fallo(FileNotFoundError("ya no está"), f.get("origen_rel") or "") except Exception as ex: # noqa fallos += 1 errores.append(f"{f.get('origen_rel')}: {ex}") + fallo(ex, f.get("origen_rel") or "") APLICANDO.update(unidades=APLICANDO["unidades"] + 1, movidos=len(movidos), fallos=fallos) # terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo» APLICANDO.update(hechas=len(filas), actual="", archivo="", unidades=unidades_total, movidos=len(movidos), @@ -2164,8 +2478,10 @@ def aplicar(raiz, filas): actualizar_inventario(movidos) # qué pasos del plan entraron (limpiar, fechas, resto, banco): Inicio les pone su ✓ con esto pasos = dict(Counter(f.get("paso") or "resto" for f in filas)) - bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores, "pasos": pasos}) - return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores} + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores, "pasos": pasos, + "motivos": motivos}) + return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores, "motivos": motivos, + "fallos": sum(m["n"] for m in motivos.values())} def actualizar_inventario(movidos): @@ -2176,25 +2492,34 @@ def actualizar_inventario(movidos): pasos siguientes salían con «0 archivos · 0 B» y sin fecha. La marca de basura/proxy heredada de una carpeta (por ejemplo lo que cuelga de «Adobe Premiere Pro Auto-Save») también se conserva: antes se recalculaba desde cero y se perdía.""" + if not movidos: + return con = db() marcas = [os.sep + p + os.sep for p in R.PAPELERAS] + # Antes de tomar la base, qué carpetas tocadas siguen existiendo: eso hay que preguntarlo al disco, pero + # todo de una vez y en paralelo. Medido el 14-oct-2026 en Vía Sumapaz, 2.493 movimientos: se preguntaba al + # disco de red dos veces por movimiento, a 41 ms cada vez con el NAS ocupado, y todo con la base tomada; + # más de diez minutos en los que «Buscar clips» se rendía con «database is locked». Lo que hay dentro de + # cada carpeta se mira en el inventario, por índice, movimiento a movimiento (no una vez al principio: una + # carpeta puede aparecer a mitad de la tanda, renombrada por un movimiento anterior). + tocadas = _tocadas(movidos) + existe = _existen({d for d, _ in tocadas}) with con: for de, a in movidos: en_papelera = 1 if any(m in a for m in marcas) else 0 con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, " "es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?", (a, os.path.dirname(a), os.path.basename(a), en_papelera, de)) - pref = de + os.sep - for r in con.execute("SELECT ruta FROM archivos WHERE ruta LIKE ? ESCAPE '!'", (esc_like(pref) + "%",)).fetchall(): + lo, hi = _rango(de + os.sep) + for r in con.execute("SELECT ruta FROM archivos WHERE ruta >= ? AND ruta < ?", (lo, hi)).fetchall(): nueva = a + r["ruta"][len(de):] con.execute("UPDATE archivos SET ruta = ?, dir = ?, " "es_basura = CASE WHEN ? = 1 OR es_basura = 1 OR rol = 'basura' THEN 1 ELSE 0 END WHERE ruta = ?", (nueva, os.path.dirname(nueva), en_papelera, r["ruta"])) # las carpetas siguen al material: se mueven de ruta, no se borran - viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", - (de, esc_like(pref) + "%")).fetchall() - con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%")) - if os.path.isdir(a): + viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi)).fetchall() + con.execute("DELETE FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi)) + if viejas: for v in viejas: nueva = a if v["ruta"] == de else a + v["ruta"][len(de):] fila = dict(v) @@ -2206,21 +2531,45 @@ def actualizar_inventario(movidos): (fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"], fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"], fila["es_proxy"], fila["es_basura"])) - _recontar_carpetas(con, movidos) + _recontar_carpetas(con, tocadas, existe) con.close() # el plan que hubiera en memoria ya no describe la carpeta: se olvida y se vuelve a pensar solo for k in {r for de, a in movidos for r in (_raiz_de(de), _raiz_de(a)) if r}: olvidar_plan(k) -def _recontar_carpetas(con, movidos): - """Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no - existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los - colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del - escaneo.""" +def _rango(prefijo): + """(desde, hasta) para pedir por índice todo lo que empieza por `prefijo`: «ruta >= desde AND ruta < hasta». + «LIKE 'X\\%'» recorría la tabla entera (76.559 filas en Sumapaz) en cada movimiento.""" + return prefijo, prefijo[:-1] + chr(ord(prefijo[-1]) + 1) + + +def _existen(rutas): + """{carpeta: si existe}, preguntando al disco en paralelo: por la red cada pregunta tarda decenas de ms.""" + rutas = list(rutas) + if not rutas: + return {} + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=24) as ex: + return dict(zip(rutas, ex.map(os.path.isdir, rutas))) + + +def _son_archivos(rutas): + """{ruta: si es un archivo}, preguntando al disco en paralelo.""" + rutas = list(rutas) + if not rutas: + return {} + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=24) as ex: + return dict(zip(rutas, ex.map(os.path.isfile, rutas))) + + +def _tocadas(movidos): + """Las carpetas cuyo recuento cambia con estos movimientos: las de origen y destino y todas las de encima. El + destino mismo entra siempre: si era un archivo, el disco dirá que no es una carpeta y no se apunta nada.""" tocadas = set() for de, a in movidos: - for ruta in (os.path.dirname(de), os.path.dirname(a), a if os.path.isdir(a) else None): + for ruta in (os.path.dirname(de), os.path.dirname(a), a): if not ruta: continue raiz = _raiz_de(ruta) @@ -2233,13 +2582,22 @@ def _recontar_carpetas(con, movidos): if padre == d: break d = padre + return tocadas + + +def _recontar_carpetas(con, tocadas, existe): + """Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no + existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los + colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del + escaneo. Si existen o no ya se preguntó al disco antes (`existe`), fuera de la base.""" marcas = [os.sep + p + os.sep for p in R.PAPELERAS] for d, raiz in tocadas: - if not os.path.isdir(d): + if not existe.get(d): con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,)) continue - fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'", - (d, esc_like(d + os.sep) + "%")).fetchone() + lo, hi = _rango(d + os.sep) + fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR (dir >= ? AND dir < ?)", + (d, lo, hi)).fetchone() previa = con.execute("SELECT * FROM carpetas WHERE ruta = ?", (d,)).fetchone() en_papelera = 1 if any(m in d + os.sep for m in marcas) else 0 con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", @@ -2884,6 +3242,20 @@ def analizar(raiz, min_mb, reintentar=False): lote = [] def guardar(lote): + # si la base está ocupada (otro proceso guardando, por ejemplo después de «Aplicar»), se espera y + # se reintenta: antes «Buscar clips» se rendía al minuto con «database is locked» + for intento in range(40): + try: + _guardar(lote) + prog.pop("esperando", None) + return + except sqlite3.OperationalError as ex: + if "locked" not in str(ex) or intento == 39: + raise + prog["esperando"] = "la base está ocupada guardando otros cambios; se espera" + time.sleep(3) + + def _guardar(lote): with con: for ruta, info in lote: if info: @@ -2921,7 +3293,8 @@ def analizar(raiz, min_mb, reintentar=False): prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)" except Exception as ex: # noqa prog["estado"] = "error" - prog["error"] = str(ex) + prog["error"] = ("la base de datos siguió ocupada guardando otros cambios más de dos minutos; vuelve a pulsar «Buscar clips»" + if "locked" in str(ex) else str(ex)) finally: con.close() @@ -6018,8 +6391,8 @@ def _nombre_tipo(t): # el orden de la lista de Inicio: lo hecho se queda en su sitio, no se va al final ni desaparece -_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "fechas", "resto", "banco", "vacias", "stock", - "repetidos", "convertir", "respaldar"] +_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "fechas", "resto", "banco", "vacias", "stock", + "repetidos", "convertir", "juntar", "respaldar"] def _historial_carpeta(raiz): @@ -6044,6 +6417,10 @@ def _historial_carpeta(raiz): continue if op == "tipo": pon("tipo", ts, f"elegiste «{_nombre_tipo(e.get('tipo'))}»") + elif op == "reloj": + pon("relojes", ts, f"{_plural(e.get('n') or 0, 'clip corrido', 'clips corridos')} {e.get('dias') or 0:+d} días") + elif op == "banco_modo": + pon("banco", ts, f"elegiste ordenarlo por {e.get('modo')}") elif op == "nombrar": pon("nombrar", ts, f"dijiste qué es «{e.get('ejemplo') or e.get('token')}»") elif op == "respaldo": @@ -6069,6 +6446,15 @@ def _historial_carpeta(raiz): return h +def _traidos_de(raiz): + """origen (minúsculas) -> cuándo se trajo, de los «Traer material» hechos a esta carpeta.""" + out = {} + for e in leer_bitacora(20000): + if e.get("op") == "fin" and (e.get("lote") or "").startswith("entrada-") and (e.get("raiz") or "").lower() == raiz.lower() and e.get("origen"): + out[os.path.normpath(e["origen"]).lower()] = e.get("ts") + return out + + def _cuando(ts): """«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40».""" if not ts: @@ -6230,6 +6616,18 @@ 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) + # --- clips con fecha imposible: una cámara con el reloj sin poner --- + rr = con.execute(f"SELECT COUNT(*) n, COUNT(DISTINCT dir || '|' || fuente) g, COALESCE(SUM(bytes), 0) b FROM archivos " + f"WHERE raiz = ? AND es_basura = 0 AND es_proxy = 0 AND rol IN ({','.join('?' * len(_ROLES_RELOJ))}) " + f"AND fecha_origen NOT IN ('nombre', 'corregida') AND substr(fecha, 1, 4) < ?", + (raiz,) + _ROLES_RELOJ + (_ANIO_RELOJ,)).fetchone() + if rr["n"]: + añadir(clave="relojes", titulo=f"Corregir la fecha de {_plural(rr['n'], 'clip', 'clips')} de cámara", boton="Corregir", + que="Su cámara tenía el reloj sin poner (2008, 2009…): se ordenarían en un año que no es.", + cifra=f"{_plural(rr['g'], 'tanda', 'tandas')} · {_gb(rr['b'])}", estado="espera", + detalle="Pones la fecha real del primer clip de cada tanda y la app corre los demás lo mismo. Se deshace.", + ir="relojes") + # --- el banco de material: los temas se quedan; dentro, por fecha y cámara --- bancos = _bancos_de(con, raiz) if bancos: @@ -6357,6 +6755,22 @@ def acciones(raiz): detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.", ir="convertir") + # --- el mismo cliente en otros discos --- + sitios = otros_sitios(raiz) + if sitios: + traidos = _traidos_de(raiz) + for s in sitios: + s["traido"] = traidos.get(os.path.normpath(s["ruta"]).lower()) + faltan = [s for s in sitios if not s["traido"]] + añadir(clave="juntar", titulo=(f"{r['cliente']} también está en {_plural(len(faltan), 'sitio', 'sitios')} más" if faltan + else f"Lo de {r['cliente']} en otros discos"), + boton="Ver" if faltan else "", estado="espera" if faltan else "hecho", + que=("Se puede traer aquí con copia comprobada: el original no se toca, y lo que llega se reparte por mes y cámara." + if faltan else "Ya está traído todo lo que había en otros discos."), + cifra=" · ".join(s["ruta"] for s in faltan[:3]) + (f" y {len(faltan) - 3} más" if len(faltan) > 3 else ""), + detalle="Míralo antes: una carpeta de entregas o de exportados puede ser una copia que conviene dejar donde está." if faltan else "", + ir="juntar", sitios=sitios) + # --- 8. respaldo, al final --- dest = CONFIG.get("respaldo", {}).get("destino", "") añadir(clave="respaldar", titulo="Copiar el respaldo a otro disco", @@ -6373,7 +6787,8 @@ def acciones(raiz): ("entrada", "Bandeja de entrada", "Vacía: lo que entró ya está repartido."), ("stock", "Stock de Envato viejo", "No queda stock viejo por apartar."), ("repetidos", "Material repetido", "No quedan copias de más por juntar."), - ("convertir", "Clips de cámara", "No quedan clips que compense convertir.")): + ("convertir", "Clips de cámara", "No quedan clips que compense convertir."), + ("relojes", "Fechas de cámara", "No queda ningún clip con fecha imposible.")): if clave in hist and clave not in hay: añadir(clave=clave, titulo=titulo, estado="hecho", que=que, cifra="", detalle="") for a in out: @@ -6641,6 +7056,11 @@ class Manejador(BaseHTTPRequestHandler): return self._json(estado_carpeta(q["raiz"])) if ruta == "/api/mapa": return self._json(mapa(q["raiz"])) + if ruta == "/api/relojes": + return self._json(relojes(q["raiz"])) + if ruta == "/api/buscar": + r = buscar(q.get("q", "")) + return self._json(r, 400 if r.get("error") else 200) 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) @@ -6831,6 +7251,22 @@ class Manejador(BaseHTTPRequestHandler): CONFIG["raices"] = d["raices"] guardar_json(CONFIG_PATH, CONFIG) return self._json({"ok": True}) + if ruta == "/api/relojes": + r = corregir_reloj(d.get("raiz") or "", d.get("dir") or "", d.get("fuente") or "", d.get("fecha") or "") + return self._json(r, 400 if r.get("error") else 200) + if ruta == "/api/relojes/deshacer": + r = deshacer_reloj(d.get("raiz") or "", d.get("grupo") or "") + return self._json(r, 400 if r.get("error") else 200) + if ruta == "/api/banco/modo": + k = _clave(d.get("raiz") or "") + modo = d.get("modo") + if not raiz_cfg(k) or modo not in ("temas", "fecha") or not d.get("nombre"): + return self._json({"error": "carpeta, banco o forma de ordenar desconocida"}, 400) + CONFIG.setdefault("bancos", {}).setdefault(k.lower(), {})[R.norm(d["nombre"])] = modo + guardar_json(CONFIG_PATH, CONFIG) + bitacora({"op": "banco_modo", "raiz": k, "banco": d["nombre"], "modo": modo}) + olvidar_plan(k) + return self._json({"ok": True, "modo": modo}) 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 "") @@ -6854,7 +7290,7 @@ class Manejador(BaseHTTPRequestHandler): # solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar", "horario", "pausar_si_edito", "aligerar", "avisar"}, - "respaldo": {"destino"}, "actualizaciones": {"auto"}} + "respaldo": {"destino"}, "actualizaciones": {"auto"}, "escaneo": {"profundo"}} for k, v in d.items(): if k in permitidas and isinstance(v, dict): CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]}) diff --git a/prueba_funcional.py b/prueba_funcional.py index e0329f0..4c0afdf 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -212,7 +212,12 @@ def p_tipo_y_pesa(): # y la ficha se queda, hecha, con lo que se eligió y cuándo: no desaparece t = {x['clave']: x for x in get('/api/carpeta/acciones' + q(raiz=RAIZ))['acciones']}['tipo'] assert t['estado'] == 'hecho' and 'Piezas mes a mes' in (t.get('hecho') or ''), t - return '«Qué pesa» entra hasta %d niveles bajo 2026; el tipo de la carpeta y el del cliente se igualan' % d2['niveles'] + # buscar en todos los clientes, y las fechas raras (aquí no hay ninguna) + b = get('/api/buscar' + q(q='reel botox')) + assert b['total'] >= 1 and all('reel botox' in x['nombre'].lower() for x in b['resultados']), b + rl = get('/api/relojes' + q(raiz=RAIZ)) + assert rl['grupos'] == [] and rl['corregidos'] == [], rl + return '«Qué pesa» entra hasta %d niveles bajo 2026; el tipo de la carpeta y el del cliente se igualan; buscar encuentra %d' % (d2['niveles'], b['total']) def p_nombrar(): @@ -640,3 +645,4 @@ if malas: print('== últimas líneas del servidor ==') print(open(os.path.join(TMP, 'servidor.log'), encoding='utf-8', errors='replace').read()[-1500:]) shutil.rmtree(TMP, ignore_errors=True) +sys.exit(1 if malas else 0) # la compilación del estudio se detiene si algo falla diff --git a/prueba_orden.py b/prueba_orden.py index 0e144f6..0d13aa4 100644 --- a/prueba_orden.py +++ b/prueba_orden.py @@ -402,6 +402,77 @@ class Motor(unittest.TestCase): 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_alta_y_baja_no_chocan(self): + """Vía Sumapaz: cada evento guardaba la misma foto en «Alta» y en «Baja»; el plan las juntaba en una + sola carpeta y 753 no se movieron. Las subcarpetas con sentido se conservan.""" + raiz = cliente("AltaBaja") + for sub, tam in (("Alta", 8192), ("Baja", 2048)): + for n in ("IMG_5154.jpg", "IMG_5155.jpg"): + mk(raiz, f"Fotos varias/2022/Enero/Evento recaudadoras/{sub}/{n}", 2022, 1, 2, tam) + mk(raiz, "Fotos varias/2022/Diciembre/Fiesta 6 Años/IMG_3135.jpg", 2022, 12, 14) + antes = nombres(raiz) + p = plan(raiz) + self.assertFalse([f for f in p["filas"] if "OJO" in f["motivo"]], [f["motivo"] for f in p["filas"]]) + por = {} + for f in p["filas"]: + for a in f["archivos"] or []: + por[a] = f["destino_rel"] + alta = [d for a, d in por.items() if os.sep + "Alta" + os.sep in a] + baja = [d for a, d in por.items() if os.sep + "Baja" + os.sep in a] + self.assertTrue(alta and baja and all(d.endswith("Alta") for d in alta) and all(d.endswith("Baja") for d in baja), por) + self.assertTrue(any("Fiesta 6 Años" in d for d in por.values()), por) + aplicar(raiz, p) # sin errores: ninguna foto choca con su gemela + self.assertEqual(nombres(raiz), antes) + + def test_reloj_de_una_camara(self): + """Una cámara con el reloj sin poner: se corrige con la fecha real del primer clip, sigue corregida al + volver a escanear, el plan la lleva a su mes de verdad, y se deshace.""" + raiz = cliente("Reloj") + mk(raiz, "Vía Mujeres/Drone/DJI_0001.MP4", 2009, 1, 19, 4096) + mk(raiz, "Vía Mujeres/Drone/DJI_0002.MP4", 2009, 1, 20, 4096) + plan(raiz) # escanea + r = O.relojes(raiz) + self.assertEqual([(g["fuente"], g["n"], g["desde"]) for g in r["grupos"]], [("DJI", 2, "2009-01-19")], r) + c = O.corregir_reloj(raiz, r["grupos"][0]["dir"], "DJI", "2024-03-12") + self.assertTrue(c.get("ok") and c["n"] == 2, c) + con = O.db() + fechas = sorted((x["fecha"][:10], x["fecha_origen"]) for x in con.execute("SELECT fecha, fecha_origen FROM archivos WHERE raiz = ? AND nombre LIKE 'DJI_%'", (raiz,))) + con.close() + self.assertEqual(fechas, [("2024-03-12", "corregida"), ("2024-03-13", "corregida")]) + p = plan(raiz) # vuelve a escanear: la corrección se mantiene + self.assertFalse(O.relojes(raiz)["grupos"]) + self.assertTrue(any("2024" in f["destino_rel"] and "03- Marzo" in f["destino_rel"] for f in p["filas"]), [f["destino_rel"] for f in p["filas"]]) + d = O.deshacer_reloj(raiz, O.relojes(raiz)["corregidos"][0]["grupo"]) + self.assertTrue(d.get("ok"), d) + self.assertEqual(O.relojes(raiz)["grupos"][0]["desde"], "2009-01-19") + # y buscar la encuentra, con tilde o sin ella + b = O.buscar("via mujeres dji_0002") + self.assertEqual([x["nombre"] for x in b["resultados"]], ["DJI_0002.MP4"], b) + self.assertEqual(O.buscar("x").get("error"), "escribe al menos dos letras") + + def test_banco_por_fecha(self): + """El banco también se puede ordenar por fecha: a la 00_Grabacion de su mes, con el tema de etiqueta.""" + raiz = cliente("BancoFecha") + mk(raiz, "Archivo (No tocar)/Lugares/Peajes/Drone/DJI_20240312101500_0001_D.MP4", 2024, 3, 12) + mk(raiz, "Archivo (No tocar)/Lugares/Peajes/Final.mp4", 2024, 4, 2) + O.CONFIG.setdefault("bancos", {})[os.path.normpath(raiz).lower()] = {O.R.norm("Archivo (No tocar)"): "fecha"} + self.addCleanup(lambda: O.CONFIG["bancos"].pop(os.path.normpath(raiz).lower(), None)) + p = plan(raiz) + self.assertEqual([b["modo"] for b in p["bancos"]], ["fecha"]) + banco = [f for f in p["filas"] if f["paso"] == "banco"] + self.assertEqual([f["destino_rel"] for f in banco], [rel("2024", "03- Marzo", "00_Grabacion", "12- Martes", "Peajes", "OSMO")]) + self.assertFalse(banco[0]["marcado"]) + + def test_otros_discos(self): + """El mismo cliente en otros discos: se ofrece traerlo, sin contar las carpetas ya registradas.""" + raiz = cliente("Repartido") + previo = dict(O._DISCOS_CACHE) + self.addCleanup(lambda: (O._DISCOS_CACHE.clear(), O._DISCOS_CACHE.update(previo))) + O._DISCOS_CACHE.update(ts=time.time(), datos={"discos": [{"letra": "K:", "unc": r"\\nas\Entregas", "nombre": ""}], + "clientes": {"Repartido": [r"K:\Repartido viejo", raiz]}}) + s = O.otros_sitios(raiz) + self.assertEqual([(x["ruta"], x["unc"]) for x in s], [(r"K:\Repartido viejo", r"\\nas\Entregas")]) + 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") @@ -1092,6 +1163,7 @@ class Convertidor(unittest.TestCase): if __name__ == "__main__": try: - unittest.main(verbosity=2, exit=False) + r = unittest.main(verbosity=2, exit=False) finally: shutil.rmtree(TMP, ignore_errors=True) + sys.exit(0 if r.result.wasSuccessful() else 1) # la compilación del estudio se detiene si algo falla diff --git a/reglas.py b/reglas.py index 8c754c8..8079dd2 100644 --- a/reglas.py +++ b/reglas.py @@ -614,7 +614,18 @@ def etiqueta_grabacion(nombre: str) -> str: def sobra(w): return w in FUENTE_PALABRAS or w.isdigit() or MESES_ALIAS.get(w, w) in MESES or w in ("-", "_") - utiles = [p for p in palabras if not all(sobra(w) for w in norm(p).split()) and norm(p)] + + def palabra_sobra(p): + return all(sobra(w) for w in norm(p).split()) + utiles = [] + for i, p in enumerate(palabras): + if not norm(p): + continue + if not palabra_sobra(p): + utiles.append(p) + elif p.isdigit() and len(p) <= 2 and 0 < i < len(palabras) - 1 \ + and not palabra_sobra(palabras[i - 1]) and not palabra_sobra(palabras[i + 1]): + utiles.append(p) # «Fiesta 6 Años»: un número entre dos palabras es parte del nombre if not utiles: return "" return " ".join(utiles) diff --git a/static/index.html b/static/index.html index 7f95edf..6d229e7 100644 --- a/static/index.html +++ b/static/index.html @@ -261,6 +261,21 @@ .ac{display:grid;grid-template-columns:26px minmax(0,1fr) auto;gap:14px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px} .ac.hecho{background:transparent;border-style:dashed;opacity:.62} .ac .hc{margin-top:4px;font-size:12.5px;font-weight:600;color:var(--ok)} + .motivos{margin-top:8px;display:grid;gap:6px;font-size:12.5px} + .motivos .mt{padding:8px 10px;border-radius:8px;background:var(--panel2);color:var(--tx)} + .motivos .ej{margin-top:3px;font-size:11.5px} + .reloj{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px 12px;border:1px solid var(--line);border-radius:8px;margin-bottom:8px;font-size:12.5px} + .reloj.hecho{border-style:dashed;opacity:.8} + .reloj .path,.sitio .path{word-break:normal;overflow-wrap:anywhere} + .reloj .acts{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end} + @media(max-width:820px){.reloj{grid-template-columns:1fr}.reloj .acts{justify-content:flex-start}} + .sitio{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:8px 0;border-top:1px solid var(--line);font-size:12.5px} + .sitio .acts{display:flex;gap:10px} + .buscar-todo{margin:10px 0 0} + .buscar-todo input{width:100%;box-sizing:border-box} + .busca td{vertical-align:top;font-size:12.5px} + .modob{margin:0 0 10px;padding:8px 12px;border-radius:8px;background:var(--panel2);font-size:12.5px;display:grid;gap:4px} + .modob .lnk{padding:0}.modob .lnk.on{font-weight:700;text-decoration:underline} .ac.hecho .hc{opacity:1} .ac.recien{opacity:1;animation:recien 3s ease-out} @keyframes recien{0%{background:var(--ok2);border-color:var(--ok);border-style:solid}70%{background:var(--ok2)}100%{background:transparent}} @@ -514,6 +529,7 @@
