3.8.0: «database is locked» y los 753 que no se movieron, arreglados; fechas raras, escaneo profundo, buscar, otros discos, banco por fecha y compilar en el estudio

- Tras «Aplicar» en una carpeta grande el inventario se ponía al día con la base tomada más de diez minutos y
  «Buscar clips» se rendía con «database is locked». Ahora las preguntas al disco van antes y en paralelo y
  lo de dentro de cada carpeta se busca por índice: 1,3 s para 2.493 movimientos, mismas filas de archivos
  que antes y 183 carpetas que antes se perdían. La lectura de clips reintenta si la base está ocupada.
- Las fotos de «Alta» y «Baja» chocaban al juntarse en una carpeta: las subcarpetas con sentido se
  conservan, el plan avisa antes de dos archivos iguales en una fila, y un número entre dos palabras
  («Fiesta 6 Años») ya no se quita.
- Los fallos de «Aplicar» se agrupan por motivo y dicen qué hacer; el banco de material se explica en llano.
- Fechas raras (el reloj de una cámara), escaneo profundo, buscar en todos los clientes, traer lo que el
  cliente tiene en otros discos, el banco por temas o por fecha, y «Calcular de nuevo» más rápido.
- Mover reintenta un momento si el archivo está abierto un instante (lectura de clips, antivirus).
- .github/workflows/publicar.yml para compilar y publicar desde un PC del estudio; las pruebas devuelven
  error si algo falla.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 08:27:27 -05:00
padre 41155cd834
commit e74b8f7ece
Se han modificado 9 ficheros con 931 adiciones y 63 borrados

92
.github/workflows/publicar.yml vendido Archivo normal
Ver fichero

@@ -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 }

Ver fichero

@@ -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 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 `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»), 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\`. 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` 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. 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 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 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 pasar» con el botón «Calcular» gris y ningún plan. Lo medido el 14-oct-2026 sobre `Y:\Sumapaz` (76.559

Ver fichero

@@ -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_LIB = {"site-packages", "test", "idlelib", "tkinter", "turtledemo", "ensurepip", "venv", "lib2to3", "pydoc_data", "__pycache__"}
SIN_DLL = ("_tkinter", "tcl", "tk8", "zlib1", "_test", "_ctypes_test", "xxlimited") 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: if not ffbin:
for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")): for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")):
ffbin = c ffbin = c

Ver fichero

@@ -42,7 +42,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.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__)) 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 # 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: 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=?", 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])) (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 = [] cfilas = []
for d, info in dirs.items(): for d, info in dirs.items():
n, b, fechas, p, bs, nombre = info n, b, fechas, p, bs, nombre = info
@@ -786,10 +787,218 @@ def lanzar_escaneo(ruta):
p = PROGRESO.get(raiz) p = PROGRESO.get(raiz)
if p and p["estado"] == "corriendo": if p and p["estado"] == "corriendo":
return False return False
threading.Thread(target=escanear, args=(raiz,), daemon=True).start() threading.Thread(target=_escanear_y_leer, args=(raiz,), daemon=True).start()
return True 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 # 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.reglas = [r for r in DICC["reglas"] if r.get("cliente") in (None, "", self.cliente)]
self.filas = [] self.filas = []
self.bancos = [] # (ruta normcase + sep, nombre, protegido): los bancos de material 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._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._bytes = None # ruta -> bytes, para partir filas de archivos
self.sueltos = defaultdict(list) # (cubeta, destino, motivo, confianza) -> 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() 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) n, bytes_ = (fila["n"], fila["bytes"]) if fila else (0, 0)
aviso_colision = "" 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": 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, # solo los primeros archivos de cada fila: para avisar basta con encontrar uno
# mover chocaría. Antes solo se comprobaba en las filas de carpeta. chk = {"tipo": "archivos", "primeros": [a[0] for a in archivos[:40]], "kw": dict(kw_destinos or {})}
especiales = kw_destinos or {} elif archivos is None and accion != "renombrar":
# solo los primeros: cada comprobación es una consulta al disco de red, y para avisar chk = {"tipo": "carpeta", "fusion": not solo_mayusculas}
# 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"
self.filas.append({ self.filas.append({
"id": len(self.filas) + 1, "id": len(self.filas) + 1,
"origen": origen, "origen": origen,
@@ -1174,6 +1376,7 @@ class Plan:
# piezas), 3 el resto (exportados, proyectos, marca, música, dudosos) # piezas), 3 el resto (exportados, proyectos, marca, música, dudosos)
"paso": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else "paso": paso or ("limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else
"fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"), "fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto"),
"_chk": chk,
}) })
def suelto(self, fila, cubeta, destino_rel, motivo, confianza): def suelto(self, fila, cubeta, destino_rel, motivo, confianza):
@@ -1229,6 +1432,7 @@ class Plan:
self.plan_auditoria() self.plan_auditoria()
self.volcar_sueltos() self.volcar_sueltos()
self.plan_basura() self.plan_basura()
self.comprobar_destinos()
self.revisar_proxies() self.revisar_proxies()
self.proteger_bancos() self.proteger_bancos()
self.con.close() self.con.close()
@@ -1238,7 +1442,8 @@ class Plan:
self.marcar_colisiones() self.marcar_colisiones()
for i, f in enumerate(self.filas, 1): for i, f in enumerate(self.filas, 1):
f["id"] = i 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): def marcar_colisiones(self):
"""Dos movimientos del mismo plan que acaban en el mismo archivo. La comprobación de `agregar` """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: if otra is not None and otra is not f:
self.avisar_colision(f, otra, os.path.basename(d)) self.avisar_colision(f, otra, os.path.basename(d))
break break
if otra is f:
self.avisar_repetido(f, os.path.basename(d))
break
destinos.setdefault(d, f) destinos.setdefault(d, f)
elif f["accion"] == "renombrar": elif f["accion"] == "renombrar":
d = f["destino"].lower() d = f["destino"].lower()
@@ -1265,6 +1473,16 @@ class Plan:
else: else:
carpetas[d] = f 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): 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}»" aviso = f" · OJO: «{otra['origen_rel']}» también va a «{fila['destino_rel']}» y las dos traen «{que}»"
if aviso not in fila["motivo"]: 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.""" 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) protegido = R.es_no_tocar(e.name)
self.bancos.append((os.path.normcase(os.path.normpath(e.path)) + os.sep, e.name, protegido)) 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) grupos = defaultdict(list)
for f in archivos_bajo(self.con, e.path): 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"]: 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) 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 # 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» # 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 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 [])) 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"])): if os.path.normcase(os.path.normpath(destino)) == os.path.normcase(os.path.normpath(f["dir"])):
continue continue
tema = segs[-1] if segs else e.name
por_que = "reloj" if reloj else "" if fiable else "mtime" 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"])) 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()): for (origen, destino, tema, conf, por_que), archivos in sorted(grupos.items()):
self.agregar(origen, destino, "GRABACION", "mover archivos", 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)", + {"": "", "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], "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") conf, archivos=archivos, marcado=False if protegido else None, paso="banco")
self.filas[-1]["no_tocar"] = protegido 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): 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, """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 # 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 # 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 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) 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: for seg in segs:
if R.detectar_anio(seg): if R.detectar_anio(seg):
@@ -1739,6 +2004,11 @@ class Plan:
repetido = True repetido = True
elif not etiqueta and R.norm(seg) not in R.DICCIONARIO_SEMILLA and not R.es_carpeta_de_tarjeta(seg): 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) 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]) fa, fm = int(f["fecha"][:4]), int(f["fecha"][5:7])
por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado» por_nombre = f["fecha_origen"] != "mtime" # «nombre», «nombre+mtime» o «derivado»
if anio and mes: if anio and mes:
@@ -1772,7 +2042,7 @@ class Plan:
hoy = datetime.date.today() hoy = datetime.date.today()
if (ay, am) > (hoy.year, hoy.month): if (ay, am) > (hoy.year, hoy.month):
ay -= 1 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 = "" aviso_fecha = ""
# hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar # hace falta que discrepe una parte de verdad: un archivo suelto traspapelado no debe dejar
# toda la carpeta sin marcar. # 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 " 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"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.") f"se grabaron, no en el que dice la carpeta.")
for (ay, am, dia, etiqueta, fuente, repetido, fconf), archivos in grupos.items(): for (ay, am, dia, etiqueta, fuente, resto, repetido, fconf), archivos in grupos.items():
sub = os.path.join(*[x for x in (etiqueta, fuente, R.DUPLICADOS if repetido else "") if x]) 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, destino = destino_plantilla(self.tipo, "GRABACION", anio=ay, mes=am, dia=dia, sub=sub,
nombre=limpiar_nombre(nombre)) nombre=limpiar_nombre(nombre))
# si todos ya están en esa carpeta, no hay nada que hacer # 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}" 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): def mover(origen, destino, lote, movidos):
"""mueve archivo o carpeta; si la carpeta destino existe, fusiona""" """mueve archivo o carpeta; si la carpeta destino existe, fusiona"""
origen = os.path.normpath(origen) 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 # 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 # 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. # 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}) bitacora({"lote": lote, "op": "mover", "de": origen, "a": destino})
movidos.append((origen, destino)) movidos.append((origen, destino))
return 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") raise FileExistsError(f"ya existe «{os.path.basename(destino)}» en el destino; no se mueve para no renombrarlo")
destino = nombre_libre(destino) destino = nombre_libre(destino)
try: try:
os.rename(origen, destino) _renombrar(origen, destino)
except OSError: except OSError:
# entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen # entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen
if os.path.isdir(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} "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): def aplicar(raiz, filas):
lote = time.strftime("%Y%m%d-%H%M%S") lote = time.strftime("%Y%m%d-%H%M%S")
movidos = [] movidos = []
errores = [] errores = []
motivos = {} # motivo -> {"n", "ejemplos"}: los fallos agrupados, para explicarlos
hechas = 0 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())) 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) unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas)
fallos = 0 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) mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
except Exception as ex: # noqa except Exception as ex: # noqa
fallos_fila += 1 fallos_fila += 1
fallo(ex, os.path.relpath(a, raiz))
if fallos_fila <= 5: if fallos_fila <= 5:
errores.append(f"{os.path.basename(a)}: {ex}") errores.append(f"{os.path.basename(a)}: {ex}")
APLICANDO.update(movidos=len(movidos), fallos=fallos + fallos_fila) APLICANDO.update(movidos=len(movidos), fallos=fallos + fallos_fila)
@@ -2149,9 +2461,11 @@ def aplicar(raiz, filas):
else: else:
fallos += 1 fallos += 1
errores.append(f"{f.get('origen_rel')}: ya no está donde decía el plan (¿se movió fuera de la app?)") 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 except Exception as ex: # noqa
fallos += 1 fallos += 1
errores.append(f"{f.get('origen_rel')}: {ex}") 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) APLICANDO.update(unidades=APLICANDO["unidades"] + 1, movidos=len(movidos), fallos=fallos)
# terminado (con o sin errores): la barra de tareas deja de mostrar «Moviendo» # 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), APLICANDO.update(hechas=len(filas), actual="", archivo="", unidades=unidades_total, movidos=len(movidos),
@@ -2164,8 +2478,10 @@ def aplicar(raiz, filas):
actualizar_inventario(movidos) actualizar_inventario(movidos)
# qué pasos del plan entraron (limpiar, fechas, resto, banco): Inicio les pone su ✓ con esto # 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)) 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}) 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} "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): 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 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 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.""" recalculaba desde cero y se perdía."""
if not movidos:
return
con = db() con = db()
marcas = [os.sep + p + os.sep for p in R.PAPELERAS] 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: with con:
for de, a in movidos: for de, a in movidos:
en_papelera = 1 if any(m in a for m in marcas) else 0 en_papelera = 1 if any(m in a for m in marcas) else 0
con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, " 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 = ?", "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)) (a, os.path.dirname(a), os.path.basename(a), en_papelera, de))
pref = de + os.sep lo, hi = _rango(de + os.sep)
for r in con.execute("SELECT ruta FROM archivos WHERE ruta LIKE ? ESCAPE '!'", (esc_like(pref) + "%",)).fetchall(): for r in con.execute("SELECT ruta FROM archivos WHERE ruta >= ? AND ruta < ?", (lo, hi)).fetchall():
nueva = a + r["ruta"][len(de):] nueva = a + r["ruta"][len(de):]
con.execute("UPDATE archivos SET ruta = ?, dir = ?, " 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 = ?", "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"])) (nueva, os.path.dirname(nueva), en_papelera, r["ruta"]))
# las carpetas siguen al material: se mueven de ruta, no se borran # las carpetas siguen al material: se mueven de ruta, no se borran
viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", viejas = con.execute("SELECT * FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi)).fetchall()
(de, esc_like(pref) + "%")).fetchall() con.execute("DELETE FROM carpetas WHERE ruta = ? OR (ruta >= ? AND ruta < ?)", (de, lo, hi))
con.execute("DELETE FROM carpetas WHERE ruta = ? OR ruta LIKE ? ESCAPE '!'", (de, esc_like(pref) + "%")) if viejas:
if os.path.isdir(a):
for v in viejas: for v in viejas:
nueva = a if v["ruta"] == de else a + v["ruta"][len(de):] nueva = a if v["ruta"] == de else a + v["ruta"][len(de):]
fila = dict(v) fila = dict(v)
@@ -2206,21 +2531,45 @@ def actualizar_inventario(movidos):
(fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"], (fila["ruta"], fila["raiz"], fila["nombre"], fila["n"], fila["bytes"],
fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"], fila["anio_moda"], fila["mes_moda"], fila["frac_moda"], fila["dias_rango"],
fila["es_proxy"], fila["es_basura"])) fila["es_proxy"], fila["es_basura"]))
_recontar_carpetas(con, movidos) _recontar_carpetas(con, tocadas, existe)
con.close() con.close()
# el plan que hubiera en memoria ya no describe la carpeta: se olvida y se vuelve a pensar solo # 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}: for k in {r for de, a in movidos for r in (_raiz_de(de), _raiz_de(a)) if r}:
olvidar_plan(k) olvidar_plan(k)
def _recontar_carpetas(con, movidos): def _rango(prefijo):
"""Las carpetas que el plan acaba de crear («00_Grabacion», «_Para borrar\\basura») o de vaciar no """(desde, hasta) para pedir por índice todo lo que empieza por `prefijo`: «ruta >= desde AND ruta < hasta».
existían en el inventario hasta el siguiente escaneo, así que «existen», las carpetas vacías y los «LIKE 'X\\%'» recorría la tabla entera (76.559 filas en Sumapaz) en cada movimiento."""
colores no las veían. Aquí entran, y las tocadas se recuentan, con totales de subárbol como los del return prefijo, prefijo[:-1] + chr(ord(prefijo[-1]) + 1)
escaneo."""
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() tocadas = set()
for de, a in movidos: 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: if not ruta:
continue continue
raiz = _raiz_de(ruta) raiz = _raiz_de(ruta)
@@ -2233,13 +2582,22 @@ def _recontar_carpetas(con, movidos):
if padre == d: if padre == d:
break break
d = padre 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] marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
for d, raiz in tocadas: for d, raiz in tocadas:
if not os.path.isdir(d): if not existe.get(d):
con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,)) con.execute("DELETE FROM carpetas WHERE ruta = ?", (d,))
continue continue
fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes), 0) b FROM archivos WHERE dir = ? OR dir LIKE ? ESCAPE '!'", lo, hi = _rango(d + os.sep)
(d, esc_like(d + os.sep) + "%")).fetchone() 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() 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 en_papelera = 1 if any(m in d + os.sep for m in marcas) else 0
con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)", con.execute("INSERT OR REPLACE INTO carpetas VALUES (?,?,?,?,?,?,?,?,?,?,?)",
@@ -2884,6 +3242,20 @@ def analizar(raiz, min_mb, reintentar=False):
lote = [] lote = []
def guardar(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: with con:
for ruta, info in lote: for ruta, info in lote:
if info: 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)" prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)"
except Exception as ex: # noqa except Exception as ex: # noqa
prog["estado"] = "error" 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: finally:
con.close() 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 # 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", _ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "fechas", "resto", "banco", "vacias", "stock",
"repetidos", "convertir", "respaldar"] "repetidos", "convertir", "juntar", "respaldar"]
def _historial_carpeta(raiz): def _historial_carpeta(raiz):
@@ -6044,6 +6417,10 @@ def _historial_carpeta(raiz):
continue continue
if op == "tipo": if op == "tipo":
pon("tipo", ts, f"elegiste «{_nombre_tipo(e.get('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": elif op == "nombrar":
pon("nombrar", ts, f"dijiste qué es «{e.get('ejemplo') or e.get('token')}»") pon("nombrar", ts, f"dijiste qué es «{e.get('ejemplo') or e.get('token')}»")
elif op == "respaldo": elif op == "respaldo":
@@ -6069,6 +6446,15 @@ def _historial_carpeta(raiz):
return h 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): def _cuando(ts):
"""«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40».""" """«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40»."""
if not ts: if not ts:
@@ -6230,6 +6616,18 @@ def acciones(raiz):
añadir(clave=clave, titulo=titulo, boton="Calcular", que=que, cifra="", estado="espera", 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) 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 --- # --- el banco de material: los temas se quedan; dentro, por fecha y cámara ---
bancos = _bancos_de(con, raiz) bancos = _bancos_de(con, raiz)
if bancos: if bancos:
@@ -6357,6 +6755,22 @@ def acciones(raiz):
detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.", detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.",
ir="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 --- # --- 8. respaldo, al final ---
dest = CONFIG.get("respaldo", {}).get("destino", "") dest = CONFIG.get("respaldo", {}).get("destino", "")
añadir(clave="respaldar", titulo="Copiar el respaldo a otro disco", 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."), ("entrada", "Bandeja de entrada", "Vacía: lo que entró ya está repartido."),
("stock", "Stock de Envato viejo", "No queda stock viejo por apartar."), ("stock", "Stock de Envato viejo", "No queda stock viejo por apartar."),
("repetidos", "Material repetido", "No quedan copias de más por juntar."), ("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: if clave in hist and clave not in hay:
añadir(clave=clave, titulo=titulo, estado="hecho", que=que, cifra="", detalle="") añadir(clave=clave, titulo=titulo, estado="hecho", que=que, cifra="", detalle="")
for a in out: for a in out:
@@ -6641,6 +7056,11 @@ class Manejador(BaseHTTPRequestHandler):
return self._json(estado_carpeta(q["raiz"])) return self._json(estado_carpeta(q["raiz"]))
if ruta == "/api/mapa": if ruta == "/api/mapa":
return self._json(mapa(q["raiz"])) 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 if ruta == "/api/mapa/carpeta": # «Qué pesa», nivel a nivel
r = carpeta_detalle(q["raiz"], q.get("ruta")) r = carpeta_detalle(q["raiz"], q.get("ruta"))
return self._json(r, 400 if r.get("error") else 200) return self._json(r, 400 if r.get("error") else 200)
@@ -6831,6 +7251,22 @@ class Manejador(BaseHTTPRequestHandler):
CONFIG["raices"] = d["raices"] CONFIG["raices"] = d["raices"]
guardar_json(CONFIG_PATH, CONFIG) guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True}) 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": 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 # 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 "") 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 # 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", permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
"horario", "pausar_si_edito", "aligerar", "avisar"}, "horario", "pausar_si_edito", "aligerar", "avisar"},
"respaldo": {"destino"}, "actualizaciones": {"auto"}} "respaldo": {"destino"}, "actualizaciones": {"auto"}, "escaneo": {"profundo"}}
for k, v in d.items(): for k, v in d.items():
if k in permitidas and isinstance(v, dict): 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]}) CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]})

Ver fichero

@@ -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 # 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'] 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 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(): def p_nombrar():
@@ -640,3 +645,4 @@ if malas:
print('== últimas líneas del servidor ==') print('== últimas líneas del servidor ==')
print(open(os.path.join(TMP, 'servidor.log'), encoding='utf-8', errors='replace').read()[-1500:]) print(open(os.path.join(TMP, 'servidor.log'), encoding='utf-8', errors='replace').read()[-1500:])
shutil.rmtree(TMP, ignore_errors=True) shutil.rmtree(TMP, ignore_errors=True)
sys.exit(1 if malas else 0) # la compilación del estudio se detiene si algo falla

Ver fichero

@@ -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.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"]]) 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): def test_el_convertidor_no_repite(self):
"""22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»).""" """22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»)."""
raiz = cliente("Convertidos") raiz = cliente("Convertidos")
@@ -1092,6 +1163,7 @@ class Convertidor(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
try: try:
unittest.main(verbosity=2, exit=False) r = unittest.main(verbosity=2, exit=False)
finally: finally:
shutil.rmtree(TMP, ignore_errors=True) 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

Ver fichero

@@ -614,7 +614,18 @@ def etiqueta_grabacion(nombre: str) -> str:
def sobra(w): def sobra(w):
return w in FUENTE_PALABRAS or w.isdigit() or MESES_ALIAS.get(w, w) in MESES or w in ("-", "_") 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: if not utiles:
return "" return ""
return " ".join(utiles) return " ".join(utiles)

Ver fichero

@@ -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{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.hecho{background:transparent;border-style:dashed;opacity:.62}
.ac .hc{margin-top:4px;font-size:12.5px;font-weight:600;color:var(--ok)} .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.hecho .hc{opacity:1}
.ac.recien{opacity:1;animation:recien 3s ease-out} .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}} @keyframes recien{0%{background:var(--ok2);border-color:var(--ok);border-style:solid}70%{background:var(--ok2)}100%{background:transparent}}
@@ -514,6 +529,7 @@
<select id="raiz" aria-hidden="true"></select> <select id="raiz" aria-hidden="true"></select>
<div id="zonaSoltar" role="button" tabindex="0" title="Suelta aquí una carpeta de cliente para añadirla, o haz clic para pegar la ruta">Suelta aquí la carpeta<br>de un cliente para añadirla</div> <div id="zonaSoltar" role="button" tabindex="0" title="Suelta aquí una carpeta de cliente para añadirla, o haz clic para pegar la ruta">Suelta aquí la carpeta<br>de un cliente para añadirla</div>
<button class="lnk" id="btnElegir" style="width:100%;margin-top:4px" title="Abre el selector de carpetas de Windows">Elegir carpeta…</button> <button class="lnk" id="btnElegir" style="width:100%;margin-top:4px" title="Abre el selector de carpetas de Windows">Elegir carpeta…</button>
<div class="buscar-todo"><input type="search" id="buscarTodo" placeholder="Buscar un archivo en todos…" aria-label="Buscar un archivo en todos los clientes" title="Escribe parte del nombre y pulsa Enter"></div>
<div class="lado-pie"><img src="/static/labstream.png" alt="Labstream Studio"><span id="reloj"></span><button id="btnBuscarAct" title="Consulta Labstream OS y avisa si hay una versión nueva">Buscar actualizaciones</button><span id="actUltima" class="mut" style="font-size:11px"></span></div> <div class="lado-pie"><img src="/static/labstream.png" alt="Labstream Studio"><span id="reloj"></span><button id="btnBuscarAct" title="Consulta Labstream OS y avisa si hay una versión nueva">Buscar actualizaciones</button><span id="actUltima" class="mut" style="font-size:11px"></span></div>
</aside> </aside>
<div id="principal"> <div id="principal">
@@ -566,7 +582,7 @@
<div id="mapaCargando" class="hint" style="display:none"><span class="spin"></span> Leyendo el inventario de la carpeta… En una carpeta grande tarda unos segundos.</div> <div id="mapaCargando" class="hint" style="display:none"><span class="spin"></span> Leyendo el inventario de la carpeta… En una carpeta grande tarda unos segundos.</div>
<div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div> <div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div>
<div id="revisarLleno" style="display:none"> <div id="revisarLleno" style="display:none">
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a></div> <div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a><a data-s="relojes">Fechas raras <span class="badge cero" id="badgeRelojes">0</span></a></div>
<div class="sub on" id="s-hay"> <div class="sub on" id="s-hay">
<div class="cards" id="cards"></div> <div class="cards" id="cards"></div>
@@ -620,6 +636,10 @@
<div class="box" id="stockCaja" style="display:none;max-height:420px;overflow:auto"><table id="tablaStock"><thead><tr><th></th><th>Carpeta</th><th>Mes de la pieza</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div> <div class="box" id="stockCaja" style="display:none;max-height:420px;overflow:auto"><table id="tablaStock"><thead><tr><th></th><th>Carpeta</th><th>Mes de la pieza</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div>
</div> </div>
<div class="sub" id="s-relojes">
<p class="ayuda">Clips cuya cámara tenía el reloj sin poner: su fecha dice 2008 o 2009, y el plan los ordenaría en un año que no es. Pon la fecha real del primer clip de cada tanda y la app corre los demás lo mismo. <span class="q">?</span><span class="mas">Se agrupan por carpeta y cámara. La pista sale de otros clips de esa carpeta que sí traen bien la fecha, o del año que diga la ruta. La corrección se guarda por archivo: sobrevive a volver a escanear y a que el plan los mueva de carpeta. Se deshace desde aquí.</span></p>
<div id="relojes" class="mut">…</div>
</div>
<div class="sub" id="s-vacias"> <div class="sub" id="s-vacias">
<p class="ayuda">Carpetas sin un solo archivo dentro: lo que queda cuando el material ya se movió. <b>Es lo único que la app borra</b>, y solo porque no hay nada que perder. <span class="q">?</span><span class="mas">Se comprueba en el disco antes de enseñarlas, y otra vez justo antes de borrarlas: si alguien dejó algo dentro mientras tanto, esa se salta y se avisa. <b>Deshacer</b> las vuelve a crear con su nombre. Las que son un enlace o una unión de Windows no se tocan nunca, aunque parezcan vacías. Fíjate en los nombres antes de marcar: una carpeta vacía llamada «03- Exfoliación química» es lo único que queda de esa pieza.</span></p> <p class="ayuda">Carpetas sin un solo archivo dentro: lo que queda cuando el material ya se movió. <b>Es lo único que la app borra</b>, y solo porque no hay nada que perder. <span class="q">?</span><span class="mas">Se comprueba en el disco antes de enseñarlas, y otra vez justo antes de borrarlas: si alguien dejó algo dentro mientras tanto, esa se salta y se avisa. <b>Deshacer</b> las vuelve a crear con su nombre. Las que son un enlace o una unión de Windows no se tocan nunca, aunque parezcan vacías. Fíjate en los nombres antes de marcar: una carpeta vacía llamada «03- Exfoliación química» es lo único que queda de esa pieza.</span></p>
<div class="toolbar"> <div class="toolbar">
@@ -753,6 +773,11 @@
<h3>Revisar y ordenar</h3> <h3>Revisar y ordenar</h3>
<dl> <dl>
<dt>Escaneo profundo</dt><dd>Después de leer nombres, tamaños y fechas, la app lee cada clip: códec, duración y resolución. Va en segundo plano y solo lee lo nuevo. <i>Cómo: se apaga en Ajustes › Escaneo; sin él, los repetidos «con otra calidad» quedan por comparar y no se sabe qué conviene convertir hasta buscar clips.</i></dd>
<dt>Fechas raras</dt><dd>Clips cuya cámara tenía el reloj sin poner (2008, 2009…). Pones la fecha real del primer clip de cada tanda y la app corre los demás lo mismo; la corrección sobrevive a volver a escanear y se deshace desde ahí.</dd>
<dt>Buscar en todos</dt><dd>El buscador de la barra lateral encuentra un archivo o una carpeta por su nombre en todas las carpetas de clientes escaneadas, con o sin tildes, y abre su carpeta.</dd>
<dt>Otros discos</dt><dd>Si el cliente también está en otros discos, Inicio lo dice y deja traer cada sitio a esta carpeta con copia comprobada, igual que «Traer material»: el original no se toca. Una carpeta de entregas o de exportados puede ser una copia que conviene dejar donde está.</dd>
<dt>Banco por fecha</dt><dd>Un banco de material se ordena por temas (los temas se quedan; dentro, por fecha y cámara) o, si lo eliges en «Qué va a pasar», por fecha: cada grabación va a la 00_Grabacion de su mes con su tema de etiqueta.</dd>
<dt>Qué pesa</dt><dd>Toca cualquier bloque para entrar en esa carpeta, nivel a nivel, hasta el fondo. Al lado dice qué guarda, de qué cámaras o grabadoras, de qué fechas, cuántos proxies y basura se pueden apartar, y qué hace la app con ella. <i>Cómo: sale del inventario del escaneo, que entra en todas las carpetas; en Inicio se ve cuántas leyó y hasta qué profundidad.</i></dd> <dt>Qué pesa</dt><dd>Toca cualquier bloque para entrar en esa carpeta, nivel a nivel, hasta el fondo. Al lado dice qué guarda, de qué cámaras o grabadoras, de qué fechas, cuántos proxies y basura se pueden apartar, y qué hace la app con ella. <i>Cómo: sale del inventario del escaneo, que entra en todas las carpetas; en Inicio se ve cuántas leyó y hasta qué profundidad.</i></dd>
<dt>Banco de material</dt><dd>Una carpeta de material ordenada por temas (Lugares, Social, Ambiental…). Los temas se quedan como están; dentro de cada uno, las grabaciones van por fecha de rodaje y por cámara: «Lugares\Peajes\Drone» pasa a «Lugares\Peajes\2024-03-12\DJI». Una carpeta cuyo nombre diga «No tocar» se trata así, y todo lo que la app propone dentro va sin marcar: lo marcas tú en «Qué va a pasar», donde el banco es un paso propio. Otra carpeta se puede marcar como banco en «Por nombrar».</dd> <dt>Banco de material</dt><dd>Una carpeta de material ordenada por temas (Lugares, Social, Ambiental…). Los temas se quedan como están; dentro de cada uno, las grabaciones van por fecha de rodaje y por cámara: «Lugares\Peajes\Drone» pasa a «Lugares\Peajes\2024-03-12\DJI». Una carpeta cuyo nombre diga «No tocar» se trata así, y todo lo que la app propone dentro va sin marcar: lo marcas tú en «Qué va a pasar», donde el banco es un paso propio. Otra carpeta se puede marcar como banco en «Por nombrar».</dd>
<dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd> <dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd>
@@ -846,6 +871,9 @@
<div class="box" style="margin-top:18px"><h3>Actualizaciones</h3> <div class="box" style="margin-top:18px"><h3>Actualizaciones</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="actAuto" style="margin-top:3px"><span>Al arrancar, consultar si hay una versión nueva en Labstream OS. <span class="mut">Es lo único que sale a la red; sin esto el programa no habla con nada fuera de este equipo. El botón «Buscar actualizaciones» sigue funcionando a mano.</span></span></label> <label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="actAuto" style="margin-top:3px"><span>Al arrancar, consultar si hay una versión nueva en Labstream OS. <span class="mut">Es lo único que sale a la red; sin esto el programa no habla con nada fuera de este equipo. El botón «Buscar actualizaciones» sigue funcionando a mano.</span></span></label>
</div> </div>
<div class="box" style="margin-top:18px"><h3>Escaneo</h3>
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="escProfundo" style="margin-top:3px"><span>Escaneo profundo: después de leer nombres, tamaños y fechas, leer cada clip (códec, duración, resolución). <span class="mut">Va en segundo plano y solo lee lo que no se había leído. Con esto se sabe qué repetidos son el mismo clip con otra calidad y qué conviene convertir. En una carpeta grande del NAS, la primera vez tarda un rato; se detiene desde Convertidor.</span></span></label>
</div>
<details class="mas" style="margin-top:18px"><summary>Avanzado: plantillas de estructura por tipo de trabajo</summary> <details class="mas" style="margin-top:18px"><summary>Avanzado: plantillas de estructura por tipo de trabajo</summary>
<div class="box" style="margin-top:10px"> <div class="box" style="margin-top:10px">
<div class="mut" style="margin-bottom:8px">Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.</div> <div class="mut" style="margin-bottom:8px">Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.</div>
@@ -932,7 +960,7 @@ function cfgRaiz(){ return (ESTADO.raices||[]).find(r => r.clave === raizActual(
function irA(t){ $$('nav a').forEach(x => x.classList.toggle('on', x.dataset.t === t)); $$('section').forEach(s => s.classList.toggle('on', s.id === 't-' + t)); pintarIndice(); cargarVista(t); } function irA(t){ $$('nav a').forEach(x => x.classList.toggle('on', x.dataset.t === t)); $$('section').forEach(s => s.classList.toggle('on', s.id === 't-' + t)); pintarIndice(); cargarVista(t); }
function vistaActual(){ return ($$('nav a').find(a => a.classList.contains('on')) || {}).dataset?.t || 'carpeta'; } function vistaActual(){ return ($$('nav a').find(a => a.classList.contains('on')) || {}).dataset?.t || 'carpeta'; }
$$('nav a').forEach(a => { a.onclick = () => irA(a.dataset.t); a.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); irA(a.dataset.t); } }; }); $$('nav a').forEach(a => { a.onclick = () => irA(a.dataset.t); a.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); irA(a.dataset.t); } }; });
function subRev(s){ const venia = $('#s-plan').classList.contains('on'); $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); if (s === 'plan' && !venia && PLAN) setTimeout(animarEjemplo, 60); } function subRev(s){ const venia = $('#s-plan').classList.contains('on'); $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); if (s === 'plan' && !venia && PLAN) setTimeout(animarEjemplo, 60); if (s === 'relojes') cargarRelojes().catch(e => aviso(e.message, 'bad')); }
$$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s)); $$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s));
const cargarVista = seguro(async t => { const cargarVista = seguro(async t => {
t = t || vistaActual(); t = t || vistaActual();
@@ -1073,6 +1101,38 @@ $('#btnTraer').onclick = () => {
$('#trAbrir').onclick = e => { e.preventDefault(); api('/api/entrada/abrir', {raiz}).then(() => aviso('Bandeja de entrada abierta en el Explorador. Lo que dejes ahí saldrá en la lista de la carpeta.', 'ok')).catch(er => aviso(er.message, 'bad')); }; $('#trAbrir').onclick = e => { e.preventDefault(); api('/api/entrada/abrir', {raiz}).then(() => aviso('Bandeja de entrada abierta en el Explorador. Lo que dejes ahí saldrá en la lista de la carpeta.', 'ok')).catch(er => aviso(er.message, 'bad')); };
}; };
// ---------------- el mismo cliente en otros discos: traerlo aquí ----------------
function juntarModal(a){
const raiz = raizActual(), sitios = a.sitios || [];
const nombreDe = s => ('De ' + s.ruta.replace(':', '')).replace(/[\\/]+/g, ' ').trim();
const filas = sitios.map((s, i) => `<div class="sitio"><div class="tx"><b class="path">${esc(s.ruta)}</b><div class="mut">${esc([s.unc, s.disco].filter(Boolean).join(' · '))}</div><div id="sitioInfo${i}" class="${s.traido ? 'ok' : 'mut'}">${s.traido ? '✓ Traído ' + esc(fecha(s.traido)) : ''}</div></div><div class="acts">${s.traido ? '' : `<button class="lnk" data-cuanto="${i}">¿Cuánto es?</button><button class="p" data-traer="${i}">Traer aquí</button>`}</div></div>`).join('');
modal(a.titulo, `<p>Cada sitio se copia a la bandeja de entrada de esta carpeta, y <b>cada archivo copiado se vuelve a leer para comprobar que quedó igual</b>. <b>El original no se toca</b>: cuando lo hayas revisado, lo borras tú si quieres.</p>${a.detalle ? `<p class="mut" style="font-size:12.5px">${esc(a.detalle)}</p>` : ''}<div class="sitios">${filas}</div><label style="display:block;margin-top:10px"><input type="checkbox" id="jnOrdenar" checked> repartirlo por mes, día de rodaje y cámara al terminar de copiar</label>`, null);
$('#modal').classList.add('ancho');
$$('#modalCuerpo [data-cuanto]').forEach(b => b.onclick = () => accion(b, async () => {
const i = b.dataset.cuanto, s = sitios[+i];
const r = await api('/api/entrada/plan', {raiz, origen: s.ruta, nombre: nombreDe(s)});
$('#sitioInfo' + i).innerHTML = `${num(r.n)} archivo${r.n !== 1 ? 's' : ''} · <b>${gb(r.bytes)}</b>${r.libre != null ? ` · en el disco de este cliente quedan ${gb(r.libre)} libres` : ''}`;
}));
$$('#modalCuerpo [data-traer]').forEach(b => b.onclick = () => accion(b, async () => {
const s = sitios[+b.dataset.traer];
const r = await api('/api/entrada/traer', {raiz, origen: s.ruta, nombre: nombreDe(s), ordenar: $('#jnOrdenar').checked});
$('#modal').classList.remove('on');
aviso(`Trayendo ${num(r.n)} archivo${r.n !== 1 ? 's' : ''} (${gb(r.bytes)}) de ${s.ruta}. El avance está en la barra de abajo; el original no se toca.`, 'ok');
await cargarEstado();
}));
}
// ---------------- buscar un archivo en todos los clientes ----------------
const buscarTodo = seguro(async () => {
const t = $('#buscarTodo').value.trim(); if (!t) return;
const d = await api('/api/buscar?q=' + encodeURIComponent(t));
const filas = d.resultados.map((r, i) => `<tr><td><b>${esc(r.nombre)}</b><div class="mut path" style="font-size:11.5px">${esc(r.rel)}</div></td><td>${esc(r.cliente)}</td><td class="mut">${esc(r.rol)}${r.apartado ? ' · apartado' : ''}</td><td class="num">${gb(r.bytes)}</td><td class="mut">${esc(fechaLarga(r.fecha))}</td><td><button class="lnk" data-abrir="${i}">Abrir la carpeta</button></td></tr>`).join('');
modal(`«${t}» en todas las carpetas`, `<p class="mut">${d.total ? `${num(d.total)} resultado${d.total !== 1 ? 's' : ''} en ${d.carpetas} carpetas de clientes${d.total > d.resultados.length ? `; aquí, los ${d.resultados.length} primeros` : ''}.` : `Nada con ese nombre en las ${d.carpetas} carpetas de clientes.`} Busca en lo escaneado: un disco sin registrar no entra.</p>${filas ? `<div style="max-height:60vh;overflow:auto"><table class="busca"><thead><tr><th>Archivo</th><th>Cliente</th><th>Qué es</th><th class="num">Peso</th><th>Fecha</th><th></th></tr></thead><tbody>${filas}</tbody></table></div>` : ''}`, null);
$('#modal').classList.add('ancho');
$$('#modalCuerpo [data-abrir]').forEach(b => b.onclick = seguro(async () => { await api('/api/abrir', {ruta: d.resultados[+b.dataset.abrir].dir}); }));
});
$('#buscarTodo').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); buscarTodo(); } };
// ---------------- orden automático (portada) ---------------- // ---------------- orden automático (portada) ----------------
const ESTADO_AUTO = {'al dia': ['ok', 'al día'], 'con dudas': ['', 'con dudas'], 'con errores': ['bad', 'con avisos'], 'demasiado': ['warn', 'sin tocar'], 'sin conexion': ['mut', 'sin conexión'], 'sin leer': ['warn', 'sin leer']}; const ESTADO_AUTO = {'al dia': ['ok', 'al día'], 'con dudas': ['', 'con dudas'], 'con errores': ['bad', 'con avisos'], 'demasiado': ['warn', 'sin tocar'], 'sin conexion': ['mut', 'sin conexión'], 'sin leer': ['warn', 'sin leer']};
async function pintarAuto(){ async function pintarAuto(){
@@ -1235,6 +1295,8 @@ const IR_ACCION = {
$('#modalSi').textContent = 'Usar este'; $('#modalSi').textContent = 'Usar este';
}, },
nombrar: () => { irA('revisar'); subRev('nombrar'); }, nombrar: () => { irA('revisar'); subRev('nombrar'); },
relojes: () => { irA('revisar'); subRev('relojes'); },
juntar: (_, a) => juntarModal(a),
stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); }, stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); },
vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); }, vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); },
mapa: () => irA('mapa'), mapa: () => irA('mapa'),
@@ -1372,6 +1434,7 @@ async function cargarMapa(){
try { m0 = await api('/api/mapa?raiz=' + encodeURIComponent(raiz)); } finally { $('#mapaCargando').style.display = 'none'; } try { m0 = await api('/api/mapa?raiz=' + encodeURIComponent(raiz)); } finally { $('#mapaCargando').style.display = 'none'; }
if (raizActual() !== raiz) return; if (raizActual() !== raiz) return;
MAPA = m0; MAPA = m0;
cargarRelojes().catch(() => {}); // la cuenta de «Fechas raras»
$('#mapaVacio').style.display = MAPA.escaneado ? 'none' : 'block'; $('#mapaVacio').style.display = MAPA.escaneado ? 'none' : 'block';
$('#revisarLleno').style.display = MAPA.escaneado ? 'block' : 'none'; $('#revisarLleno').style.display = MAPA.escaneado ? 'block' : 'none';
cargarDiscos().catch(() => {}); cargarDiscos().catch(() => {});
@@ -1469,6 +1532,32 @@ async function cargarDiscos(){
$('#discos').innerHTML = `<table><thead><tr><th>Unidad</th><th>Nombre</th><th>Nivel</th><th style="width:22%">Uso</th><th class="num">Libre</th><th>Carpetas de primer nivel</th></tr></thead><tbody>${d.discos.map(x => `<tr><td><b>${x.letra}</b><div class="cell-small">${esc(x.unc)}</div></td><td>${esc(x.nombre)}</td><td><span class="tag">${esc(x.nivel)}</span></td><td><div class="bar"><i style="width:${Math.round((x.total-x.libre)/x.total*100)}%;background:${(x.total-x.libre)/x.total > .9 ? 'var(--bad)' : 'var(--acc)'}"></i></div><div class="cell-small">${gb(x.total-x.libre)} de ${gb(x.total)}</div></td><td class="num">${gb(x.libre)}</td><td>${x.carpetas.slice(0,40).map(c => `<span class="chip" style="${c.cliente ? 'border-color:var(--acc)' : ''}" title="${esc(c.cliente||'')}">${esc(c.nombre)}</span>`).join('')}${x.carpetas.length > 40 ? ` <span class="mut">+${x.carpetas.length-40}</span>` : ''}</td></tr>`).join('')}</tbody></table>`; $('#discos').innerHTML = `<table><thead><tr><th>Unidad</th><th>Nombre</th><th>Nivel</th><th style="width:22%">Uso</th><th class="num">Libre</th><th>Carpetas de primer nivel</th></tr></thead><tbody>${d.discos.map(x => `<tr><td><b>${x.letra}</b><div class="cell-small">${esc(x.unc)}</div></td><td>${esc(x.nombre)}</td><td><span class="tag">${esc(x.nivel)}</span></td><td><div class="bar"><i style="width:${Math.round((x.total-x.libre)/x.total*100)}%;background:${(x.total-x.libre)/x.total > .9 ? 'var(--bad)' : 'var(--acc)'}"></i></div><div class="cell-small">${gb(x.total-x.libre)} de ${gb(x.total)}</div></td><td class="num">${gb(x.libre)}</td><td>${x.carpetas.slice(0,40).map(c => `<span class="chip" style="${c.cliente ? 'border-color:var(--acc)' : ''}" title="${esc(c.cliente||'')}">${esc(c.nombre)}</span>`).join('')}${x.carpetas.length > 40 ? ` <span class="mut">+${x.carpetas.length-40}</span>` : ''}</td></tr>`).join('')}</tbody></table>`;
} }
// ---------------- revisar: fechas raras (una cámara con el reloj sin poner) ----------------
const fechaLarga = f => { const [a, m, d] = (f || '').split('-'); return d ? `${+d} ${MESES_UI[+m - 1].slice(0, 3).toLowerCase()} ${a}` : (f || ''); };
async function cargarRelojes(){
const raiz = raizActual(); if (!raiz) return;
const d = await api('/api/relojes?raiz=' + encodeURIComponent(raiz));
if (raizActual() !== raiz) return;
const bd = $('#badgeRelojes'); bd.textContent = d.grupos.length; bd.classList.toggle('cero', !d.grupos.length);
$('#relojes').classList.remove('mut');
$('#relojes').innerHTML = (d.grupos.length ? d.grupos.map((g, i) => `<div class="reloj"><div class="tx"><b class="path">${esc(g.rel)}</b><div class="mut">${esc(g.camara || 'cámara sin identificar')} · ${num(g.n)} clip${g.n !== 1 ? 's' : ''} · ${gb(g.bytes)} · por ejemplo «${esc(g.ejemplo)}»</div><div>La cámara dice <b>${fechaLarga(g.desde)}</b>${g.hasta !== g.desde ? ` a <b>${fechaLarga(g.hasta)}</b>` : ''}.${g.sugerencia ? ` <span class="mut">Pista: ${esc(g.por_que)}.</span>` : ''}</div></div><div class="acts"><label>El primero se grabó el <input type="date" data-f="${i}" value="${esc(g.sugerencia || '')}"></label><button class="p" data-c="${i}">Corregir</button></div></div>`).join('')
: '<p class="ok" style="margin:6px 0">No queda ningún clip con fecha imposible.</p>')
+ (d.corregidos.length ? '<div class="cabsec" style="margin-top:16px">Ya corregidas</div>' + d.corregidos.map((c, i) => `<div class="reloj hecho"><div class="tx">✓ <b class="path">${esc(c.rel || '')}</b> · ${esc(c.camara || '')} · ${num(c.n)} clip${c.n !== 1 ? 's' : ''} corrido${c.n !== 1 ? 's' : ''} ${c.dias > 0 ? '+' : ''}${num(c.dias)} días · ${esc(fecha(c.ts))}</div><div class="acts"><button class="lnk" data-d="${i}">Deshacer</button></div></div>`).join('') : '');
$$('#relojes [data-c]').forEach(b => b.onclick = () => {
const g = d.grupos[+b.dataset.c], f = $(`#relojes [data-f="${b.dataset.c}"]`).value;
if (!f) { aviso('Pon la fecha en que se grabó el primer clip.', 'bad'); return; }
accion(b, async () => {
const r = await api('/api/relojes', {raiz, dir: g.dir, fuente: g.fuente, fecha: f});
aviso(`Listo: ${num(r.n)} clip${r.n !== 1 ? 's' : ''} con su fecha real (${r.dias > 0 ? '+' : ''}${num(r.dias)} días). El plan se vuelve a calcular.`, 'ok');
PLAN = null; estadoPlan(); await cargarRelojes();
});
});
$$('#relojes [data-d]').forEach(b => b.onclick = () => accion(b, async () => {
await api('/api/relojes/deshacer', {raiz, grupo: d.corregidos[+b.dataset.d].grupo});
aviso('Corrección deshecha: esos clips vuelven a la fecha de su cámara.', 'ok'); PLAN = null; estadoPlan(); await cargarRelojes();
}));
}
// ---------------- revisar: por nombrar ---------------- // ---------------- revisar: por nombrar ----------------
async function cargarVoc(){ async function cargarVoc(){
if (!raizActual() || !(MAPA && MAPA.escaneado)) { $('#nombrar').innerHTML = '<li class="mut">Escanea primero.</li>'; return; } if (!raizActual() || !(MAPA && MAPA.escaneado)) { $('#nombrar').innerHTML = '<li class="mut">Escanea primero.</li>'; return; }
@@ -1637,18 +1726,33 @@ function animarEjemplo(){
setTimeout(() => { if (v.isConnected) fin(); }, 1500); setTimeout(() => { if (v.isConnected) fin(); }, 1500);
} }
// ---- la lista: de dónde → a dónde, con la cuenta; el porqué solo si se pide // ---- la lista: de dónde → a dónde, con la cuenta; el porqué solo si se pide
// lo que comparten el origen y el destino: así se dice solo lo que cambia
function comunes(o, d){ const a = trozos(o), b = trozos(d); let i = 0; while (i < a.length && i < b.length && a[i].toLowerCase() === b[i].toLowerCase()) i++; return {comun: a.slice(0, i), soloO: a.slice(i), soloD: b.slice(i)}; }
const corta = segs => segs.length > 2 ? '…\\' + segs.slice(-2).join('\\') : segs.join('\\');
function deHtml(r){ function deHtml(r){
if (r.archivos && r.paso === 'banco') {
const c = comunes(r.origen_rel, r.destino_rel), cam = ultimo(r.destino_rel);
return `<b>${r.archivos.length} clip${r.archivos.length > 1 ? 's' : ''} de ${esc(NOMBRE_CAMARA[cam] || cam)}</b><span class="en">en el tema «${esc(ultimo(c.comun.join('\\')) || r.banco || '')}»${c.soloO.length ? `, dentro de «${esc(c.soloO.join(' › '))}»` : ''}</span>`;
}
if (r.archivos && r.origen_rel === '(toda la raíz)') return `<b>${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}</b><span class="en">por toda la carpeta</span>`; if (r.archivos && r.origen_rel === '(toda la raíz)') return `<b>${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}</b><span class="en">por toda la carpeta</span>`;
if (r.archivos) return `<b>${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''}</b><span class="en">en ${esc(r.origen_rel)}</span>`; if (r.archivos) return `<b>${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''}</b><span class="en">en ${esc(r.origen_rel)}</span>`;
return `<b>${esc(ultimo(r.origen_rel))}</b><span class="en">en ${esc(padreDe(r.origen_rel) || 'la raíz')}</span>`; return `<b>${esc(ultimo(r.origen_rel))}</b><span class="en">en ${esc(padreDe(r.origen_rel) || 'la raíz')}</span>`;
} }
function aHtml(r){ function aHtml(r){
if (r.archivos && r.paso === 'banco') {
const c = comunes(r.origen_rel, r.destino_rel);
if (c.comun.length && !/^\d{4}$/.test(c.soloD[0] || '') && /^\d{4}-\d{2}-\d{2}$/.test(c.soloD[0] || '')) return `se quedan en el mismo tema, en <b>${esc(c.soloD.join(' › '))}</b> <span class="mut">(día de rodaje › cámara)</span>`;
return `<b>${esc(corta(trozos(r.destino_rel)))}</b>`;
}
if (r.accion === 'renombrar') return `pasa a llamarse <b>${esc(ultimo(r.destino_rel))}</b>`; if (r.accion === 'renombrar') return `pasa a llamarse <b>${esc(ultimo(r.destino_rel))}</b>`;
if (!r.archivos && ultimo(r.destino_rel) === ultimo(r.origen_rel)) return esc(padreDe(r.destino_rel) || 'la raíz'); if (!r.archivos && ultimo(r.destino_rel) === ultimo(r.origen_rel)) return esc(padreDe(r.destino_rel) || 'la raíz');
return esc(r.destino_rel); return esc(r.destino_rel);
} }
// el nombre de cámara de cada carpeta de fuente, para decirlo en llano
const NOMBRE_CAMARA = {URSA_BRAW: 'la URSA (BRAW)', BLACKMAGIC_MP4: 'la Blackmagic (MP4)', SONY: 'la Sony', OSMO: 'la Osmo', DJI: 'el dron DJI', DJI_MIC: 'el DJI Mic', TASCAM: 'la Tascam', ZOOM: 'la Zoom', ATOMOS: 'el Atomos', CELULAR: 'celular', FOTOS: 'cámara de fotos', AUDIO: 'grabadora', OTROS: 'otra cámara'};
const SEG_EXPLICA = {alta: 'La app está segura.', media: 'Probable: la fecha sale del reloj de la cámara, que suele estar bien.', baja: 'Dudoso: la app no está segura; míralo antes de marcarlo.'};
function filaHtml(r, ej){ function filaHtml(r, ej){
return `<div class="fila ${r.confianza} ${r.marcado ? '' : 'sinmarcar'} ${ej && ej.id === r.id ? 'esej' : ''}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"><div class="de">${deHtml(r)}</div><span class="fl">→</span><div class="a">${aHtml(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="meta">${r.n ? `${num(r.n)} arch. · ${gb(r.bytes)}` : ''}</div><div class="acts">${r.confianza !== 'alta' ? `<span class="tag ${r.confianza}">${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>` : ''}<button class="ed" data-ej="${r.id}" title="Verlo arriba, como ejemplo">ejemplo</button>${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div><div class="pq">${esc(motivoLlano(r.motivo))}</div></div>`; return `<div class="fila ${r.confianza} ${r.marcado ? '' : 'sinmarcar'} ${ej && ej.id === r.id ? 'esej' : ''}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"><div class="de">${deHtml(r)}</div><span class="fl">→</span><div class="a">${aHtml(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="meta">${r.n ? `${num(r.n)} arch. · ${gb(r.bytes)}` : ''}</div><div class="acts">${r.confianza !== 'alta' ? `<span class="tag ${r.confianza}" title="${esc(r.cosmetico ? 'Solo cambia la forma del nombre.' : SEG_EXPLICA[r.confianza] || '')}">${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>` : ''}<button class="ed" data-ej="${r.id}" title="Verlo arriba, como ejemplo">ejemplo</button>${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div><div class="pq">${esc(motivoLlano(r.motivo))}</div></div>`;
} }
function pintarLista(){ function pintarLista(){
const caja = $('#planLista'), p = PASOS_PLAN.find(x => x.k === PLAN_PASO); const caja = $('#planLista'), p = PASOS_PLAN.find(x => x.k === PLAN_PASO);
@@ -1659,11 +1763,21 @@ function pintarLista(){
const grupos = rs => { const g = {}; rs.forEach(r => (g[r.cubeta] = g[r.cubeta] || []).push(r)); return Object.keys(g).map(c => `<div class="grp">${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}</div>` + g[c].map(r => filaHtml(r, ej)).join('')).join(''); }; const grupos = rs => { const g = {}; rs.forEach(r => (g[r.cubeta] = g[r.cubeta] || []).push(r)); return Object.keys(g).map(c => `<div class="grp">${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}</div>` + g[c].map(r => filaHtml(r, ej)).join('')).join(''); };
const nt = sin.filter(r => r.no_tocar), cos = sin.filter(r => r.cosmetico && !r.no_tocar), dud = sin.filter(r => !r.cosmetico && !r.no_tocar); const nt = sin.filter(r => r.no_tocar), cos = sin.filter(r => r.cosmetico && !r.no_tocar), dud = sin.filter(r => !r.cosmetico && !r.no_tocar);
const explica = [nt.length ? `${nt.length === 1 ? 'uno está' : nt.length + ' están'} dentro de una carpeta que dice «No tocar»: no se mueve${nt.length === 1 ? '' : 'n'} hasta que ${nt.length === 1 ? 'lo' : 'los'} marques tú` : '', cos.length ? `${cos.length === 1 ? 'uno solo cambia' : cos.length + ' solo cambian'} el nombre a la forma «NN- Título» (como «01 - Celular» → «01- Celular»): da igual hacerlo o no` : '', dud.length ? `${dud.length} ${dud.length === 1 ? 'es dudoso' : 'son dudosos'}: la app no está segura y no ${dud.length === 1 ? 'lo' : 'los'} mueve sin que lo marques tú` : ''].filter(Boolean).join('; '); const explica = [nt.length ? `${nt.length === 1 ? 'uno está' : nt.length + ' están'} dentro de una carpeta que dice «No tocar»: no se mueve${nt.length === 1 ? '' : 'n'} hasta que ${nt.length === 1 ? 'lo' : 'los'} marques tú` : '', cos.length ? `${cos.length === 1 ? 'uno solo cambia' : cos.length + ' solo cambian'} el nombre a la forma «NN- Título» (como «01 - Celular» → «01- Celular»): da igual hacerlo o no` : '', dud.length ? `${dud.length} ${dud.length === 1 ? 'es dudoso' : 'son dudosos'}: la app no está segura y no ${dud.length === 1 ? 'lo' : 'los'} mueve sin que lo marques tú` : ''].filter(Boolean).join('; ');
caja.innerHTML = `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}${PLAN_PASO === 'banco' && sin.length > 1 ? `<button class="lnk" id="btnMarcarBanco">marcar los ${sin.length}</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`; const bancos = (PLAN && PLAN.bancos) || [];
const modoB = PLAN_PASO === 'banco' && bancos.length ? `<div class="modob">${bancos.map((b, i) => `<div>Cómo se ordena «${esc(b.nombre)}»: <button class="lnk ${b.modo !== 'fecha' ? 'on' : ''}" data-modob="${i}" data-m="temas">por temas</button> · <button class="lnk ${b.modo === 'fecha' ? 'on' : ''}" data-modob="${i}" data-m="fecha">por fecha</button> <span class="mut">${b.modo === 'fecha' ? 'Cada grabación va a la 00_Grabacion de su mes, con su tema de etiqueta; lo montado se queda en el banco.' : 'Los temas se quedan; dentro de cada uno, por fecha de rodaje y cámara.'}</span></div>`).join('')}</div>` : '';
const ejB = PLAN_PASO === 'banco' ? filasPaso('banco').find(r => r.archivos) : null;
const explicaB = ejB ? `<div class="hint" style="margin:0 0 10px">Este paso no saca nada del banco: <b>cada tema se queda donde está</b>, y dentro de él las grabaciones se separan <b>por día de rodaje y por cámara</b>. Por ejemplo, los ${ejB.archivos.length} clips de <span class="path">${esc(corta(trozos(ejB.origen_rel)))}</span> pasan a <span class="path">${esc(corta(trozos(ejB.destino_rel)))}</span>. Lo montado (exportados, proyectos, fotos sueltas) no se toca. Va todo sin marcar porque la carpeta dice «No tocar»: si te convence, «marcar los ${filasPaso('banco').length}» y aplicar; se deshace desde Historial.</div>` : '';
caja.innerHTML = modoB + explicaB + `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}${PLAN_PASO === 'banco' && sin.length > 1 ? `<button class="lnk" id="btnMarcarBanco">marcar los ${sin.length}</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`;
$('#chkPorQue').onchange = e => { PLAN_POR_QUE = e.target.checked; caja.querySelector('.lista').classList.toggle('porque', PLAN_POR_QUE); }; $('#chkPorQue').onchange = e => { PLAN_POR_QUE = e.target.checked; caja.querySelector('.lista').classList.toggle('porque', PLAN_POR_QUE); };
const bo = $('#btnVerOpc'); if (bo) bo.onclick = () => { PLAN_VER_OPC = !PLAN_VER_OPC; pintarLista(); }; const bo = $('#btnVerOpc'); if (bo) bo.onclick = () => { PLAN_VER_OPC = !PLAN_VER_OPC; pintarLista(); };
const bc = $('#btnMarcarCosm'); if (bc) bc.onclick = () => { cos.forEach(r => r.marcado = true); pintarPlan(); }; const bc = $('#btnMarcarCosm'); if (bc) bc.onclick = () => { cos.forEach(r => r.marcado = true); pintarPlan(); };
const bb = $('#btnMarcarBanco'); if (bb) bb.onclick = () => { sin.forEach(r => r.marcado = true); pintarPlan(); }; const bb = $('#btnMarcarBanco'); if (bb) bb.onclick = () => { sin.forEach(r => r.marcado = true); pintarPlan(); };
$$('#planLista [data-modob]').forEach(b => b.onclick = () => accion(b, async () => {
const bco = bancos[+b.dataset.modob]; if (bco.modo === b.dataset.m) return;
await api('/api/banco/modo', {raiz: raizActual(), nombre: bco.nombre, modo: b.dataset.m});
PLAN = null; PLAN_PASO = 'banco'; await calcularPlan();
aviso(b.dataset.m === 'fecha' ? `«${bco.nombre}» se ordena ahora por fecha.` : `«${bco.nombre}» se ordena ahora por temas.`, 'ok');
}));
// marcar o desmarcar una fila cambia las cifras, no la lista: así el foco se queda donde está // marcar o desmarcar una fila cambia las cifras, no la lista: así el foco se queda donde está
$$('#planLista .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; el.classList.toggle('sinmarcar', !r.marcado); pintarCabPlan(); estadoPlan(); }; }); $$('#planLista .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; el.classList.toggle('sinmarcar', !r.marcado); pintarCabPlan(); estadoPlan(); }; });
$$('#planLista [data-ej]').forEach(b => b.onclick = () => { const i = ejemplosDe(PLAN_PASO).findIndex(x => x.id === +b.dataset.ej); if (i >= 0) { PLAN_EJ = i; pintarEjemplo(); pintarLista(); $('#ejCaja').scrollIntoView({behavior: 'smooth', block: 'nearest'}); } }); $$('#planLista [data-ej]').forEach(b => b.onclick = () => { const i = ejemplosDe(PLAN_PASO).findIndex(x => x.id === +b.dataset.ej); if (i >= 0) { PLAN_EJ = i; pintarEjemplo(); pintarLista(); $('#ejCaja').scrollIntoView({behavior: 'smooth', block: 'nearest'}); } });
@@ -1679,11 +1793,27 @@ function pintarAvance(s){
caja.innerHTML = `<div class="avance"><div class="l1"><span class="spin"></span><b>Moviendo…</b><span>${u} % · ${s.hechas || 0} de ${s.total} cambio${s.total > 1 ? 's' : ''}</span><span class="sep"></span><span class="mut">${num(s.movidos || 0)} movimiento${s.movidos === 1 ? '' : 's'}${s.fallos ? ` · <span class="bad">${s.fallos} no se pudieron</span>` : ''}</span></div><div class="bar grande"><i style="width:${u}%"></i></div><div class="l3 mut">${s.actual ? `ahora: «${esc(s.actual)}»${s.archivo ? ' · ' + esc(s.archivo) : ''}` : 'empezando…'}</div></div>`; caja.innerHTML = `<div class="avance"><div class="l1"><span class="spin"></span><b>Moviendo…</b><span>${u} % · ${s.hechas || 0} de ${s.total} cambio${s.total > 1 ? 's' : ''}</span><span class="sep"></span><span class="mut">${num(s.movidos || 0)} movimiento${s.movidos === 1 ? '' : 's'}${s.fallos ? ` · <span class="bad">${s.fallos} no se pudieron</span>` : ''}</span></div><div class="bar grande"><i style="width:${u}%"></i></div><div class="l3 mut">${s.actual ? `ahora: «${esc(s.actual)}»${s.archivo ? ' · ' + esc(s.archivo) : ''}` : 'empezando…'}</div></div>`;
return; return;
} }
const r = s.res, falt = r.errores.length; const r = s.res, falt = r.fallos != null ? r.fallos : r.errores.length;
caja.innerHTML = `<div class="avance listo ${falt ? 'conerr' : ''}"><div class="l1"><span class="ico">${falt ? '!' : '✓'}</span><b>${falt ? 'Hecho, con tropiezos.' : 'Listo.'}</b><span>${r.filas} de ${s.pedidas} cambio${s.pedidas > 1 ? 's' : ''} · ${num(r.movimientos)} movimiento${r.movimientos === 1 ? '' : 's'} · ${gb(s.bytes)} · ${tiempoDe(s.seg)}</span><span class="sep"></span>${r.lote ? `<button class="lnk" id="avDeshacer">Deshacer esto</button>` : ''}</div><div class="bar grande"><i style="width:100%"></i></div>${falt ? `<div class="l3 bad">${falt} no se pudieron mover. <button class="lnk" id="avErr" style="padding:0 4px">ver cuáles</button></div>` : ''}<div class="l3 mut" id="avQuedan"><span class="spin"></span> mirando qué queda…</div></div>`; caja.innerHTML = `<div class="avance listo ${falt ? 'conerr' : ''}"><div class="l1"><span class="ico">${falt ? '!' : '✓'}</span><b>${falt ? 'Hecho, con tropiezos.' : 'Listo.'}</b><span>${r.filas} de ${s.pedidas} cambio${s.pedidas > 1 ? 's' : ''} · ${num(r.movimientos)} movimiento${r.movimientos === 1 ? '' : 's'} · ${gb(s.bytes)} · ${tiempoDe(s.seg)}</span><span class="sep"></span>${r.lote ? `<button class="lnk" id="avDeshacer">Deshacer esto</button>` : ''}</div><div class="bar grande"><i style="width:100%"></i></div>${falt ? `<div class="l3 bad">${num(falt)} no se pudieron mover. Nada se perdió: se quedaron donde estaban. <button class="lnk" id="avErr" style="padding:0 4px">ver cuáles</button></div>${motivosHtml(r.motivos)}` : ''}<div class="l3 mut" id="avQuedan"><span class="spin"></span> mirando qué queda…</div></div>`;
if (r.lote) $('#avDeshacer').onclick = () => deshacer(r.lote); if (r.lote) $('#avDeshacer').onclick = () => deshacer(r.lote);
if (falt) $('#avErr').onclick = () => modal('No se pudieron mover', `<pre>${esc(r.errores.join('\n'))}</pre>`, null); if (falt) $('#avErr').onclick = () => modal('No se pudieron mover', `<pre>${esc(r.errores.join('\n'))}</pre>`, null);
} }
// por qué no se pudo mover algo, en llano, y qué hacer con cada clase de fallo
const MOTIVO_FALLO = {
existe: ['en el destino ya había un archivo con el mismo nombre', 'La app nunca renombra (Resolve y Premiere enlazan por nombre), así que esos se quedaron donde estaban. Suele ser la misma foto o el mismo clip dos veces, por ejemplo en «Alta» y en «Baja»: mira «Juntar material repetido», o calcula de nuevo y cambia el destino de esa fila.'],
en_uso: ['estaban abiertos en otro programa', 'Cierra Resolve, Premiere o la ventana del Explorador que los tenga abiertos, pulsa «Calcular de nuevo» y aplica: solo intentará lo que faltó.'],
permiso: ['Windows no dio permiso para moverlos', 'Suele ser una carpeta del NAS de solo lectura o de otro usuario. Revisa los permisos de esa carpeta compartida.'],
no_esta: ['ya no estaban donde decía el plan', 'Alguien los movió o los borró después de calcular. Pulsa «Calcular de nuevo».'],
copia: ['la copia entre discos no quedó completa', 'El original no se tocó. Vuelve a aplicar; si se repite, mira el espacio libre y la conexión con el NAS.'],
red: ['se cortó la conexión con el disco de red', 'Comprueba que la unidad sigue conectada y vuelve a aplicar: solo intentará lo que faltó.'],
otro: ['por otra razón', 'Mira la lista con «ver cuáles».'],
};
function motivosHtml(m){
const ks = Object.keys(m || {}).sort((a, b) => m[b].n - m[a].n);
if (!ks.length) return '';
return `<div class="motivos">${ks.map(k => { const [que, hacer] = MOTIVO_FALLO[k] || MOTIVO_FALLO.otro; const x = m[k];
return `<div class="mt"><b>${num(x.n)} porque ${esc(que)}.</b> ${esc(hacer)}${x.ejemplos && x.ejemplos.length ? `<div class="mut ej">Por ejemplo: ${x.ejemplos.slice(0, 3).map(e => `<span class="path">${esc(e)}</span>`).join(', ')}</div>` : ''}</div>`; }).join('')}</div>`;
}
function pintarQueda(){ function pintarQueda(){
const el = $('#avQuedan'); if (!el || !PLAN) return; const el = $('#avQuedan'); if (!el || !PLAN) return;
const sin = PLAN.filas.filter(r => !r.marcado), marc = PLAN.filas.filter(r => r.marcado); const sin = PLAN.filas.filter(r => !r.marcado), marc = PLAN.filas.filter(r => r.marcado);
@@ -2091,6 +2221,9 @@ function pintarAjustes(){
const ac = $('#actAuto'); const ac = $('#actAuto');
ac.checked = (ESTADO.config.actualizaciones || {}).auto === true; ac.checked = (ESTADO.config.actualizaciones || {}).auto === true;
ac.onchange = () => api('/api/config', {actualizaciones: {auto: ac.checked}}).then(cargarEstado).then(() => aviso(ac.checked ? 'Al arrancar consultará si hay versión nueva.' : 'Ya no sale a la red al arrancar.', 'ok')).catch(e => aviso(e.message, 'bad')); ac.onchange = () => api('/api/config', {actualizaciones: {auto: ac.checked}}).then(cargarEstado).then(() => aviso(ac.checked ? 'Al arrancar consultará si hay versión nueva.' : 'Ya no sale a la red al arrancar.', 'ok')).catch(e => aviso(e.message, 'bad'));
const ep = $('#escProfundo');
ep.checked = (ESTADO.config.escaneo || {}).profundo !== false;
ep.onchange = () => api('/api/config', {escaneo: {profundo: ep.checked}}).then(cargarEstado).then(() => aviso(ep.checked ? 'Escaneo profundo: después de escanear se leerá cada clip.' : 'El escaneo ya no lee los clips: solo nombres, tamaños y fechas.', 'ok')).catch(e => aviso(e.message, 'bad'));
if (!AJ) { if (!AJ) {
const c = ESTADO.config; AJ = {clientes: []}; const c = ESTADO.config; AJ = {clientes: []};
Object.entries(c.clientes).forEach(([k, v]) => AJ.clientes.push({nombre: k, orig: k, tipo: v.tipo, alias: (v.alias || []).join('; '), rutas: (c.raices || []).filter(r => r.cliente === k).map(r => r.ruta)})); Object.entries(c.clientes).forEach(([k, v]) => AJ.clientes.push({nombre: k, orig: k, tipo: v.tipo, alias: (v.alias || []).join('; '), rutas: (c.raices || []).filter(r => r.cliente === k).map(r => r.ruta)}));

39
verificar_publicado.py Archivo normal
Ver fichero

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""Comprueba lo que sirve Labstream OS después de publicar: la versión, que la firma del manifiesto es válida, que
es el mismo manifiesto que el local, y que el paquete que se descarga tiene la huella y el tamaño que dice.
Termina con error si algo no cuadra (la compilación del estudio se detiene ahí).
py -3.12 verificar_publicado.py
"""
import os
import sys
import io
import json
import hashlib
import urllib.request
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
REPO = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, REPO)
import firma # noqa: E402
import actualizador as A # noqa: E402
BASE = "https://os.labstreamsas.com"
def pedir(url):
return urllib.request.urlopen(urllib.request.Request(url, headers={"Cache-Control": "no-cache"}), timeout=120).read()
m = json.loads(pedir(BASE + "/herramientas/organizador.json"))
local = json.load(open(os.path.join(REPO, "manifiesto.json"), encoding="utf-8"))
firma_ok = firma.verificar(firma.texto_manifiesto(m), str(m["firma"]["valor"]), A.CLAVE_PUBLICA)
igual = m == local
datos = pedir(BASE + m["app"]["url"])
huella_ok = hashlib.sha256(datos).hexdigest() == m["app"]["sha256"] and len(datos) == m["app"]["size"]
print("servidor: versión", m["version"], "· local:", local.get("version"))
print("firma del manifiesto:", "válida" if firma_ok else "NO VÁLIDA")
print("coincide con el manifiesto local:", igual)
print(f"descarga del paquete: {len(datos)} bytes · huella y tamaño iguales a los del manifiesto: {huella_ok}")
print("notas:", (m.get("notes") or "")[:120], "…")
sys.exit(0 if (firma_ok and igual and huella_ok) else 1)