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