3.3.0: lo que entra nuevo ya entra ordenado
Las cuatro funciones que eligió Munka tras la 3.2.0: - Nueva pieza: crea «NN- Título» en su mes con el número que toca y sus subcarpetas; si la pieza ya existe, lo dice. Se deshace mientras siga vacía. - Bandeja de entrada: lo que se deje en _Entrada se reparte por mes, día de rodaje y cámara. «Traer material» copia una tarjeta comprobando cada archivo (se vuelve a leer y se compara la huella), conserva la fecha del rodaje y no toca el origen. - Versiones del mismo clip: dos clips de cámara que se llaman igual, duran lo mismo y tienen la misma forma son el mismo clip; se queda el de más imagen y el otro va a Duplicados. - Orden automático, apagado de fábrica: una tarea del Programador de tareas de Windows lanza «automatico», que lee todas las carpetas y hace solo lo seguro. No toca una carpeta que no se pueda leer entera ni una cuyo plan mueva más de 3.000 archivos, y si el programa ya está abierto se lo pide a él. Desinstalar quita la tarea. De paso: una ruta que empieza por guion bajo ya no pierde el guion al ir a la papelera. Pruebas: prueba_orden.py 24, probar.py 19, prueba_funcional.py 29, prueba_ventana.py 18 desde el código y 17 con el paquete. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
53
README.md
53
README.md
@@ -817,6 +817,59 @@ y repartida en sus meses, y una segunda pasada no propuso nada marcado. Pruebas:
|
||||
19 casos que reproducen cada uno de estos puntos en pequeño; `probar.py` 19 de 19, `prueba_funcional.py` 26
|
||||
de 26 y `prueba_ventana.py` 18.
|
||||
|
||||
3.3.0, lo que entra nuevo ya entra ordenado. Tras la 3.2.0 Munka eligió cuatro funciones de las nueve que
|
||||
se le propusieron. Las cuatro atacan el sitio donde nace el desorden: el día que se vuelca la tarjeta y el
|
||||
día que se crea la carpeta de una pieza.
|
||||
|
||||
- **Bandeja de entrada.** La carpeta `_Entrada` dentro de la del cliente. El plan la abre siempre: lo que
|
||||
se deje ahí (una tarjeta volcada, clips sueltos, una carpeta con el nombre de la sesión) va a su mes, a su
|
||||
día de rodaje y a su cámara. En la lista de la carpeta sale como «Repartir», que vuelve a leer la carpeta
|
||||
y aplica solo lo que sale de la bandeja y va marcado (`ordenar_entrada`); lo dudoso se queda dentro. La
|
||||
bandeja no se borra al vaciarse ni cuenta como carpeta vacía, y el esqueleto de la tarjeta (PRIVATE,
|
||||
M4ROOT, CLIP…) se quita solo, apuntado en el mismo lote para que «Deshacer» lo rehaga. De una tarjeta
|
||||
Sony, `SUB` cuenta como proxies y `THMBNL`, `GENERAL` y `MEDIAPRO.XML` como lo que son: índices de la
|
||||
cámara, a `_Para borrar`.
|
||||
- **Traer material** (`traer`). Copia una tarjeta, o cualquier carpeta de fuera, a la bandeja y la reparte.
|
||||
Cada archivo se copia a un nombre provisional, se vuelve a leer entero y se compara su huella (BLAKE2)
|
||||
con la del original; solo entonces toma su nombre, con la fecha del archivo conservada, que es la del
|
||||
rodaje. La lista de lo copiado con su huella queda en `.organilab\entradas\` dentro de la carpeta del
|
||||
cliente. Del origen solo se lee. Una copia cortada se retoma; un archivo que ya está y es distinto no se
|
||||
pisa. No se trae de dentro de la carpeta del cliente ni de la de otro cliente registrado, y se comprueba
|
||||
antes que cabe.
|
||||
- **Nueva pieza** (`nueva_pieza`). Crea «NN- Título» en su mes con el número siguiente al mayor que haya y
|
||||
las subcarpetas de `config.json` › `pieza.subcarpetas` (las que más se repiten en las piezas del estudio).
|
||||
Si la pieza ya existe, aunque esté escrita distinto, lo dice y no crea otra. En clientes de campañas o de
|
||||
proyecto usa su plantilla, sin número. Queda en la bitácora como `mkdir`: «Deshacer» la quita mientras
|
||||
siga vacía. Una pieza recién creada, y el mes o el año que se crearon para ella, no salen entre las
|
||||
carpetas vacías por borrar durante 30 días.
|
||||
- **Versiones del mismo clip** (`_mismas_tomas`). Dos clips de cámara que se llaman igual y pesan distinto
|
||||
pueden ser el mismo clip con dos calidades, o dos clips de dos tarjetas (las dos traen un C0001.MP4). Se
|
||||
leen con ffprobe: si duran lo mismo y tienen la misma forma de imagen son el mismo clip, se queda el de
|
||||
más imagen (a igualdad, el que más pesa) y el otro va a `Duplicados` a su lado; si no, se dejan. Solo
|
||||
material de cámara: la primera versión comparaba cualquier video, y sobre la carpeta de Galeano juntaba
|
||||
la entrega vertical y la horizontal de una pieza, que se llaman igual y duran lo mismo. Es la tercera
|
||||
clase de «Juntar repetidos», que ahora tiene botones propios en «Qué hay» («Comparar y ver todos»,
|
||||
«Juntar en Duplicados»); hasta ahora solo se llegaba por «hacer de una vez». En esa carpeta hay 24 clips
|
||||
de un rodaje guardados dos veces (los de 99 MB y su copia de 31 MB): con la ligera apartada, la fila de
|
||||
39 clips que el plan dejaba sin marcar ya no choca y puede ir a su mes.
|
||||
- **Orden automático** (`automatico`). Viene apagado. Encendido desde la portada, da de alta una tarea del
|
||||
usuario en el Programador de tareas de Windows («OrganiLAB - orden automatico», diaria, a la hora
|
||||
elegida) que lanza `OrganizadorLabstream.exe automatico`, sin consola ni ventana. La pasada recorre las
|
||||
carpetas registradas: escanea cada una y hace lo mismo que «hacer de una vez». Tres frenos: si la carpeta
|
||||
no está conectada o no se pudo leer entera, no la toca; si el plan mueve más de `automatico.limite`
|
||||
archivos (3.000), tampoco, y lo dice; y si el programa ya está abierto, la tarea se lo pide a él por su
|
||||
puerto en vez de trabajar a la vez. El resumen queda en `datos\automatico.json` y en la portada, carpeta
|
||||
por carpeta, con «Hacerlo ahora» para lanzarlo a mano. Con `ORGANILAB_ENSAYO` puesto, la tarea se escribe
|
||||
en un archivo en vez de darse de alta: así lo prueban las pruebas.
|
||||
|
||||
De paso: `destino_plantilla` recortaba el guion bajo inicial de cualquier ruta que entrara en una variable,
|
||||
así que lo apartado desde `_Entrada\…` llegaba a la papelera como `Entrada\…`; ahora solo recorta lo que deja
|
||||
una variable vacía. Y cada cuadro de confirmación de la interfaz vuelve a abrir con su botón habilitado.
|
||||
|
||||
Pruebas: `prueba_orden.py` 24 casos (5 nuevos: pieza, bandeja, tarjeta con una copia falseada que tiene
|
||||
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.
|
||||
|
||||
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
|
||||
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
|
||||
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:
|
||||
|
||||
@@ -134,6 +134,10 @@ def tarea_en_marcha():
|
||||
return "hay un escaneo en marcha"
|
||||
if any((a or {}).get("estado") == "corriendo" for a in O.ANALISIS.values()):
|
||||
return "se están leyendo clips"
|
||||
if getattr(O, "TRAER", {}).get("estado") in ("copiando", "ordenando"):
|
||||
return "se está trayendo material de una tarjeta"
|
||||
if getattr(O, "AUTO", {}).get("activo"):
|
||||
return "el orden automático está en marcha"
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@@ -109,7 +109,8 @@ def diagnostico(raiz):
|
||||
if not frases:
|
||||
frases.append("Nada llamativo: la carpeta no tiene grandes bolsas de peso que recortar.")
|
||||
return {"raiz": raiz, "n": len(filas), "bytes": total_b, "grupos": grupos, "frases": frases, "meses_stock": meses,
|
||||
"duplicados": {"grupos": len(dup["grupos"]), "n": dup["n"], "bytes": dup["bytes"], "identicos": dup["identicos"], "versiones": dup["versiones"]},
|
||||
"duplicados": {"grupos": len(dup["grupos"]), "n": dup["n"], "bytes": dup["bytes"], "identicos": dup["identicos"], "versiones": dup["versiones"],
|
||||
"por_comparar": dup.get("por_comparar", 0)},
|
||||
"escaneo": dict(esc) if esc else None}
|
||||
|
||||
|
||||
@@ -147,12 +148,14 @@ def pasos(diag, plan_filas):
|
||||
[("no", "Por ahora no (se puede hacer luego desde la pestaña Optimizar)"),
|
||||
("si", "Sí, convertir y reemplazar los originales (los originales quedan en _Para borrar/originales)")], "no")
|
||||
dup = diag.get("duplicados") or {}
|
||||
if dup.get("grupos"):
|
||||
if dup.get("grupos") or dup.get("por_comparar"):
|
||||
det = []
|
||||
if dup.get("identicos"):
|
||||
det.append(f"{dup['identicos']} idénticos (mismo nombre y peso)")
|
||||
if dup.get("versiones"):
|
||||
det.append(f"{dup['versiones']} en dos formatos (MP4 y MOV del mismo clip)")
|
||||
if dup.get("por_comparar"):
|
||||
det.append(f"{dup['por_comparar']} clips que se llaman igual y pesan distinto (se comparan: si duran lo mismo, se queda el de más calidad)")
|
||||
paso("duplicados", "Material repetido",
|
||||
f"{dup['n']} copias en {dup['grupos']} grupos ({O._gb(dup['bytes'])}): {' y '.join(det)}. No se borra nada: cada copia se junta en una subcarpeta Duplicados al lado del que se queda, para revisarla a mano.",
|
||||
[("apartar", "Juntarlas en Duplicados junto al original (recomendado)"), ("dejar", "Dejarlas donde están")], "apartar")
|
||||
|
||||
14
instalar.py
14
instalar.py
@@ -25,6 +25,7 @@ import ctypes
|
||||
import shutil
|
||||
import winreg
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from ctypes import wintypes as W
|
||||
|
||||
import ventana as V
|
||||
@@ -35,8 +36,10 @@ DESCRIPCION = "OrganiLAB · ordena y aligera el material de video"
|
||||
# «equipo» es esa carpeta (sus programas, su menú Inicio y su Escritorio) y la línea de Aplicaciones va a
|
||||
# una clave aparte del registro. Lo usa prueba_ventana.py.
|
||||
ENSAYO = os.environ.get("ORGANILAB_ENSAYO", "")
|
||||
CLAVE = r"Software\OrganiLAB-ensayo\Alta" if ENSAYO else r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB"
|
||||
CLAVE_DE_VERDAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\OrganiLAB"
|
||||
CLAVE = r"Software\OrganiLAB-ensayo\Alta" if ENSAYO else CLAVE_DE_VERDAD
|
||||
MARCA_MUDANZA = "instalado-en.txt" # en la carpeta de origen: adónde se instaló lo que había aquí
|
||||
TAREA_AUTOMATICO = "OrganiLAB - orden automatico" # la tarea de Windows del orden automático (organizador.TAREA_WINDOWS)
|
||||
INVENTARIO = "inventario.sqlite"
|
||||
NO_VIAJA = {"actualizacion", "__pycache__", MARCA_MUDANZA} # del primer nivel: lo que es de paso
|
||||
NO_VIAJA_DATOS = {"sesion.json", "ventana"} # de datos\: la sesión abierta y el perfil del motor, que se rehace solo
|
||||
@@ -347,6 +350,15 @@ def desinstalar(base=None):
|
||||
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, CLAVE)
|
||||
except OSError:
|
||||
pass
|
||||
# si estaba encendido el orden automático, su tarea de Windows se va con el programa: si no,
|
||||
# seguiría intentando abrir cada noche algo que ya no está. Solo cuando se desinstala de verdad:
|
||||
# las pruebas trabajan contra una clave de ensayo y no deben tocar la tarea de nadie.
|
||||
if not ENSAYO and CLAVE == CLAVE_DE_VERDAD:
|
||||
try:
|
||||
subprocess.run(["schtasks", "/Delete", "/F", "/TN", TAREA_AUTOMATICO], capture_output=True, timeout=30,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return {"accesos": quitados}
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"nombre": "OrganiLAB",
|
||||
"version": "3.2.0",
|
||||
"builtAt": "2026-10-04T21:33:00.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-3.2.0.zip",
|
||||
"size": 195403245,
|
||||
"sha256": "e029a6010a72817b719fdefc66a9669f11e9cd037a48e8c76f5f91c99f82ee58",
|
||||
"version": "3.3.0",
|
||||
"builtAt": "2026-10-04T22:05:28.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-3.3.0.zip",
|
||||
"size": 195421712,
|
||||
"sha256": "452fea4d67e9e1fc675813ef766a10c1fea4c9e810391faa3cf49d84144738dd",
|
||||
"app": {
|
||||
"archivo": "OrganizadorLabstream-app-3.2.0.zip",
|
||||
"size": 11590666,
|
||||
"sha256": "dcef264edc282a83f00073c6d7bec8aa6a799996e49a255483c1ea6187c6720e",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-3.2.0.zip"
|
||||
"archivo": "OrganizadorLabstream-app-3.3.0.zip",
|
||||
"size": 11609133,
|
||||
"sha256": "b321a8c9b2ef166f8d291c31fe66388abf7675f568b71f5f6f0dff36e6bfceb4",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-3.3.0.zip"
|
||||
},
|
||||
"notes": "El orden mira la forma de las carpetas y no solo su nombre: «Por nombrar» pregunta solo lo que importa, una pieza o un cajón con meses dentro ya no se desarman, se repasa lo ya ordenado, y el convertidor no repite lo convertido. Pide escanear otra vez cada carpeta.",
|
||||
"notes": "Lo nuevo entra ya ordenado: «Nueva pieza» crea la carpeta con su número y sus subcarpetas, la bandeja de entrada reparte lo que se deje en _Entrada, «Traer material» copia una tarjeta comprobando cada archivo, «Juntar repetidos» reconoce el mismo clip con dos calidades, y el orden automático (apagado de fábrica) pone al día todas las carpetas cada día.",
|
||||
"firma": {
|
||||
"alg": "rsa2048-pkcs1v15-sha256",
|
||||
"valor": "2d0254434190db0e98469d7988923419aff8364bf0b72378aa528dfa0ef3fb5abf9c1620b11ebfa7ba679f9e7863c81edab027c0ce783f31fb61032b938ecee86e0f3bdf9a8d999cebe1701668983c4b3df48db2061ff011ab1a9b026ca2d6b124fdf92b6ea6df0d75d69c41d7e6ac3235bd9b8a058cf20b98bed026d2ff574802314b6542eeed1f89df914f777c3d06c0aaa9c656b64972edd274cb1b41c2baf94c9d4d05381241c56e4b621681ec8eafb29f88b7c64a111665c47247b05e584f19f4e70ee06ee3d163600dac3f70515f8fec4d09dfc1c151337058189bf02d5f5064112ee4d0989c2118d7f5e981a9fa77938d5aa8e839c58092250a1f29ac"
|
||||
"valor": "14cf8c856a36db6c1f5a876b1f2418e5a15fe08e6c911593fbb14b49cf4f42af9e8e26f77fac63629212e2a2a71b2a4089dd40bc2cdf703f5263572a07e59b317294b41462543015d51d8db15121fd9c1a944b6f9efaa9c2cd32d7b171468ca9fd18367bfbed5be5b5f9c676d4db50928c33f1808b8eb5097ce59fce2b6077baee7d8ca05989d303b043d7cb48e1c9c56904df1cffd7ab568d6d398ff21fe89de9c62c89190cfaa29ac814a98c534f4d56b8af75a2fa7281075124d564b41b9a8939aa8594234402d5a0bb08633ce43eaddd68b1ff8c12b53d6d0a496d664e7eed5e069661229dfa69bdcdfaaceff89a7b2a727286622c83d3e856bb3724bff8"
|
||||
}
|
||||
}
|
||||
840
organizador.py
840
organizador.py
La diferencia del archivo ha sido suprimido porque es demasiado grande
Cargar Diff
@@ -169,7 +169,15 @@ class Windows(unittest.TestCase):
|
||||
# quitarlo desde otra copia del programa no toca lo de esta; desde esta, sí
|
||||
self.assertEqual(instalar.desinstalar(tmp)["accesos"], [])
|
||||
self.assertTrue(instalar.instalado_aqui(base))
|
||||
# desinstalar de verdad quita también la tarea del orden automático; contra la clave de ensayo
|
||||
# no debe ni intentarlo: aquí se comprueba que no llama al Programador de tareas
|
||||
llamadas, correr = [], instalar.subprocess.run
|
||||
instalar.subprocess.run = lambda *a, **k: llamadas.append(a)
|
||||
try:
|
||||
self.assertEqual(instalar.desinstalar(base)["accesos"], [inicio])
|
||||
finally:
|
||||
instalar.subprocess.run = correr
|
||||
self.assertEqual(llamadas, [])
|
||||
self.assertIsNone(instalar.instalado())
|
||||
finally:
|
||||
for clave in (instalar.CLAVE, r"Software\OrganiLAB-ensayo"):
|
||||
|
||||
@@ -78,7 +78,9 @@ if FF:
|
||||
with socket.socket() as s_:
|
||||
s_.bind(('127.0.0.1', 0))
|
||||
PUERTO = s_.getsockname()[1]
|
||||
env = dict(os.environ, ORGANIZADOR_PUERTO=str(PUERTO), PYTHONIOENCODING='utf-8')
|
||||
ENSAYO = os.path.join(TMP, 'equipo')
|
||||
os.makedirs(ENSAYO)
|
||||
env = dict(os.environ, ORGANIZADOR_PUERTO=str(PUERTO), PYTHONIOENCODING='utf-8', ORGANILAB_ENSAYO=ENSAYO)
|
||||
log = open(os.path.join(TMP, 'servidor.log'), 'w', encoding='utf-8')
|
||||
proc = subprocess.Popen([sys.executable, 'organizador.py', '--sin-navegador'], cwd=APP, env=env, stdout=log, stderr=subprocess.STDOUT)
|
||||
BASE = f'http://127.0.0.1:{PUERTO}'
|
||||
@@ -380,6 +382,84 @@ def p_asistente():
|
||||
return 'pasos + «hacer las N de una vez» terminan sin error'
|
||||
|
||||
|
||||
def p_pieza():
|
||||
hoy = time.localtime()
|
||||
v = get('/api/pieza/previa' + q(raiz=RAIZ, titulo='Reel de prueba', anio=hoy.tm_year, mes=hoy.tm_mon))
|
||||
assert v['rel'].endswith('Reel de prueba') and not v['existe'], v
|
||||
assert not os.path.exists(v['ruta']), 'la vista previa creó la carpeta'
|
||||
c, r = post('/api/pieza', {'raiz': RAIZ, 'titulo': 'Reel de prueba', 'anio': hoy.tm_year, 'mes': hoy.tm_mon})
|
||||
assert c == 200 and r.get('ok'), r
|
||||
assert sorted(os.listdir(r['ruta'])) == sorted(r['subcarpetas']) and len(r['subcarpetas']) >= 5, os.listdir(r['ruta'])
|
||||
c2, r2 = post('/api/pieza', {'raiz': RAIZ, 'titulo': 'reel de prueba', 'anio': hoy.tm_year, 'mes': hoy.tm_mon})
|
||||
assert c2 == 400 and 'ya existe' in r2.get('error', ''), (c2, r2)
|
||||
vac = [x['rel'] for x in get('/api/vacias' + q(raiz=RAIZ))['carpetas']]
|
||||
assert not any('Reel de prueba' in x for x in vac), 'ofrece borrar una pieza recién creada: %s' % vac
|
||||
c3, r3 = post('/api/deshacer', {'lote': r['lote']})
|
||||
assert c3 == 200 and not r3['errores'] and not os.path.exists(r['ruta']), r3
|
||||
return 'crea «%s» con %d subcarpetas, no repite, y se deshace' % (r['nombre'], len(r['subcarpetas']))
|
||||
|
||||
|
||||
def p_entrada():
|
||||
tarjeta = os.path.join(TMP, 'tarjeta')
|
||||
clip = os.path.join(tarjeta, 'DCIM', '100MEDIA', 'DJI_0007.MP4')
|
||||
os.makedirs(os.path.dirname(clip))
|
||||
with open(clip, 'wb') as fh:
|
||||
fh.write(os.urandom(700_000))
|
||||
t = time.mktime(time.struct_time((2026, 8, 10, 12, 0, 0, 0, 0, -1)))
|
||||
os.utime(clip, (t, t))
|
||||
c, p = post('/api/entrada/plan', {'raiz': RAIZ, 'origen': tarjeta})
|
||||
assert c == 200 and p['n'] == 1 and p['bytes'] == 700_000, p
|
||||
c, mal = post('/api/entrada/plan', {'raiz': RAIZ, 'origen': os.path.join(RAIZ, '2026')})
|
||||
assert c == 400 and mal.get('error'), mal
|
||||
c, r = post('/api/entrada/traer', {'raiz': RAIZ, 'origen': tarjeta})
|
||||
assert c == 200 and r.get('lanzado'), r
|
||||
assert esperar(lambda: get('/api/estado')['traer'].get('estado') not in ('copiando', 'ordenando'), 120)
|
||||
tr = get('/api/estado')['traer']
|
||||
assert tr['estado'] == 'listo' and tr['copiados'] == 1 and not tr['errores_n'], tr
|
||||
final = os.path.join(RAIZ, '2026', '08- Agosto', '00_Grabacion', 'DJI', 'DJI_0007.MP4')
|
||||
assert os.path.isfile(final), 'no llegó a su sitio: %s' % [x for x in arbol() if 'dji' in x]
|
||||
assert os.path.getsize(final) == 700_000 and abs(os.path.getmtime(final) - t) < 2, 'la copia no conserva peso o fecha'
|
||||
assert os.path.isfile(clip), 'tocó la tarjeta'
|
||||
e = get('/api/entrada' + q(raiz=RAIZ))
|
||||
assert e['n'] == 0 and e['existe'], e
|
||||
# y lo que se deja a mano en la bandeja sale en la lista y se reparte con su botón
|
||||
suelto = os.path.join(RAIZ, '_Entrada', 'C0099.MP4')
|
||||
with open(suelto, 'wb') as fh:
|
||||
fh.write(os.urandom(4096))
|
||||
os.utime(suelto, (t, t))
|
||||
a = get('/api/carpeta/acciones' + q(raiz=RAIZ))
|
||||
assert any(x['clave'] == 'entrada' and x['estado'] == 'toca' for x in a['acciones']), [x['clave'] for x in a['acciones']]
|
||||
c, o = post('/api/entrada/ordenar', {'raiz': RAIZ})
|
||||
assert c == 200 and o['movimientos'] >= 1 and not o['errores'] and o['quedan'] == 0, o
|
||||
assert os.path.isfile(os.path.join(RAIZ, '2026', '08- Agosto', '00_Grabacion', 'SONY', 'C0099.MP4')), [x for x in arbol() if 'c0099' in x]
|
||||
return 'trae de una tarjeta con copia comprobada y la reparte; lo dejado a mano en la bandeja también'
|
||||
|
||||
|
||||
def p_automatico():
|
||||
c, r = post('/api/automatico/config', {'activo': True, 'hora': '03:15'})
|
||||
assert c == 200 and r['activo'] and r['hora'] == '03:15', r
|
||||
tarea = json.load(open(os.path.join(ENSAYO, 'tarea.json'), encoding='utf-8'))
|
||||
assert tarea['activo'] and tarea['orden'].endswith(' automatico'), tarea
|
||||
c, mal = post('/api/automatico/config', {'activo': True, 'hora': 'a las tres'})
|
||||
assert c == 400, mal
|
||||
c, r = post('/api/automatico/ahora', {})
|
||||
assert c == 200 and r.get('lanzado'), r
|
||||
assert esperar(lambda: not get('/api/estado')['automatico'].get('activo'), 180)
|
||||
u = get('/api/automatico')['ultimo']
|
||||
assert u and len(u['carpetas']) == 1 and u['carpetas'][0]['estado'] in ('al dia', 'con dudas'), u
|
||||
assert not u['carpetas'][0]['errores'], u['carpetas'][0]
|
||||
# lo que lanza la tarea de Windows cuando el programa ya está abierto: se lo pide a él y se va, para
|
||||
# que no haya dos procesos moviendo lo mismo
|
||||
r = subprocess.run([sys.executable, 'organizador.py', 'automatico'], cwd=APP, env=env, timeout=120)
|
||||
assert r.returncode == 0, 'la orden «automatico» salió con %s' % r.returncode
|
||||
assert esperar(lambda: not get('/api/estado')['automatico'].get('activo'), 180)
|
||||
u2 = get('/api/automatico')['ultimo']
|
||||
assert u2['origen'] == 'programado' and u2['ts'] > u['ts'], u2
|
||||
c, r = post('/api/automatico/config', {'activo': False, 'hora': '03:15'})
|
||||
assert c == 200 and not r['activo'], r
|
||||
return 'se enciende (tarea de Windows en ensayo), hace una pasada y deja el resumen (%s); con el programa abierto, la tarea se lo pide a él' % u['carpetas'][0]['estado']
|
||||
|
||||
|
||||
def p_soltar_y_buscar():
|
||||
s = get('/api/soltado' + q(nombre='nada'))
|
||||
assert s['ruta'] is None
|
||||
@@ -424,6 +504,7 @@ for nombre, fn in (('Interfaz sin nada externo', p_estatico), ('Registrar carpet
|
||||
('Motores de conversión', p_motores), ('Leer clips (ffprobe)', p_analizar),
|
||||
('Convertir, verificar, reemplazar', p_convertir), ('Control de la cola', p_cola_control),
|
||||
('Respaldar', p_respaldo), ('Asistente / hacer de una vez', p_asistente),
|
||||
('Pieza nueva', p_pieza), ('Bandeja de entrada y tarjeta', p_entrada), ('Orden automático', p_automatico),
|
||||
('Arrastrar y buscar carpeta', p_soltar_y_buscar), ('Actualizaciones', p_actualizacion_offline),
|
||||
('Clientes, reglas', p_clientes_config), ('Quitar de la lista', p_quitar)):
|
||||
prueba(nombre, fn)
|
||||
|
||||
219
prueba_orden.py
219
prueba_orden.py
@@ -382,6 +382,225 @@ class Motor(unittest.TestCase):
|
||||
self.assertTrue(any("versión anterior" in x for x in O.Plan(O.raiz_cfg(raiz)).correr()["avisos"]))
|
||||
|
||||
|
||||
class Nuevo(unittest.TestCase):
|
||||
"""Lo que entra nuevo: una pieza, el material de una tarjeta, y la pasada automática."""
|
||||
|
||||
def test_pieza_nueva(self):
|
||||
"""Las piezas se creaban a mano: dos «01» en el mismo mes, «03 - X» junto a «03- X», «portasda»."""
|
||||
raiz = cliente("Piezas")
|
||||
mk(raiz, "2026/10- Octubre/01- Uno/a.mp4", 2026, 10, 1)
|
||||
mk(raiz, "2026/10- Octubre/02- Dos/b.mp4", 2026, 10, 2)
|
||||
plan(raiz)
|
||||
previa = O.nueva_pieza(raiz, "Ozempic face", 2026, 10, crear=False)
|
||||
self.assertEqual((previa["rel"], previa["existe"]), (rel("2026", "10- Octubre", "03- Ozempic face"), ""))
|
||||
self.assertFalse(os.path.exists(previa["ruta"]), "la vista previa no debe crear nada")
|
||||
r = O.nueva_pieza(raiz, "Ozempic face", 2026, 10)
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
subs = sorted(os.listdir(r["ruta"]))
|
||||
self.assertEqual(subs, sorted(O.CONFIG_DEFECTO["pieza"]["subcarpetas"]))
|
||||
self.assertIn("ya existe", O.nueva_pieza(raiz, "ozempic FACE", 2026, 10).get("error", "")) # la misma, escrita distinto
|
||||
self.assertEqual(O.nueva_pieza(raiz, "07- Otra cosa", 2026, 10, crear=False)["nombre"], "04- Otra cosa") # el número lo pone la app
|
||||
for malo in ("", "a/b", "x" * 90):
|
||||
self.assertTrue(O.nueva_pieza(raiz, malo, 2026, 10).get("error"), malo)
|
||||
mes_nuevo = O.nueva_pieza(raiz, "Primera", 2026, 11)
|
||||
self.assertEqual(mes_nuevo["rel"], rel("2026", "11- Noviembre", "01- Primera"))
|
||||
# una pieza recién creada no es un hueco que borrar, ni el mes que se creó para ella
|
||||
self.assertEqual(O.carpetas_vacias(raiz)["carpetas"], [])
|
||||
self.assertFalse([f for f in O.Plan(O.raiz_cfg(raiz)).correr()["filas"]], "el plan quiere mover una pieza recién creada")
|
||||
# deshacer la quita si sigue vacía; si ya tiene algo dentro, la deja
|
||||
mk(raiz, "2026/11- Noviembre/01- Primera/Guion/guion.docx", 2026, 11, 2)
|
||||
d = O.deshacer(mes_nuevo["lote"])
|
||||
self.assertTrue(os.path.isfile(os.path.join(mes_nuevo["ruta"], "Guion", "guion.docx")) and d["errores"], d)
|
||||
d = O.deshacer(r["lote"])
|
||||
self.assertFalse(os.path.exists(r["ruta"]) or d["errores"], d)
|
||||
# donde las piezas no van por mes (campañas), sin número
|
||||
anuncios = cliente("Piezas de pauta", "pauta")
|
||||
self.assertEqual(O.nueva_pieza(anuncios, "Navidad", 2026, 12)["rel"], rel("Campañas", "2026_Navidad"))
|
||||
|
||||
def test_bandeja_de_entrada(self):
|
||||
"""Lo que se deja en _Entrada se reparte solo por rodaje y cámara, y la bandeja queda vacía."""
|
||||
raiz = cliente("Bandeja")
|
||||
mk(raiz, "2026/09- Septiembre/01- Pieza/a.png", 2026, 9, 1)
|
||||
t = "_Entrada/Tarjeta A/PRIVATE/M4ROOT/"
|
||||
mk(raiz, t + "CLIP/C0001.MP4", 2026, 9, 20)
|
||||
mk(raiz, t + "CLIP/C0001M01.XML", 2026, 9, 20)
|
||||
mk(raiz, t + "MEDIAPRO.XML", 2026, 9, 20)
|
||||
mk(raiz, t + "THMBNL/C0001T01.JPG", 2026, 9, 20)
|
||||
mk(raiz, t + "SUB/C0001S03.MP4", 2026, 9, 20)
|
||||
mk(raiz, "_Entrada/A001_09211200_C001.braw", 2026, 9, 21)
|
||||
mk(raiz, "_Entrada/Entrevista Dra/C0005.MP4", 2026, 9, 22)
|
||||
antes = nombres(raiz)
|
||||
plan(raiz)
|
||||
self.assertEqual([a["clave"] for a in O.acciones(raiz)["acciones"] if a["clave"] == "entrada"], ["entrada"])
|
||||
self.assertGreaterEqual(O.entrada(raiz)["n"], 4)
|
||||
r = O.ordenar_entrada(raiz)
|
||||
self.assertTrue(r.get("ok") and r["movimientos"] and not r["errores"], r)
|
||||
self.assertEqual(nombres(raiz), antes)
|
||||
g = os.path.join(raiz, "2026", "09- Septiembre", "00_Grabacion")
|
||||
for esperado in (os.path.join(g, R.nombre_dia("2026-09-20"), "SONY", "C0001.MP4"),
|
||||
os.path.join(g, R.nombre_dia("2026-09-20"), "SONY", "C0001M01.XML"),
|
||||
os.path.join(g, R.nombre_dia("2026-09-21"), "URSA_BRAW", "A001_09211200_C001.braw"),
|
||||
os.path.join(g, R.nombre_dia("2026-09-22"), "Entrevista Dra", "SONY", "C0005.MP4"),
|
||||
os.path.join(raiz, "_Para borrar", "proxies", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "SUB", "C0001S03.MP4"),
|
||||
os.path.join(raiz, "_Para borrar", "basura", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "THMBNL", "C0001T01.JPG"),
|
||||
os.path.join(raiz, "_Para borrar", "basura", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "MEDIAPRO.XML")):
|
||||
self.assertTrue(os.path.isfile(esperado), "falta " + os.path.relpath(esperado, raiz) + "\n" + "\n".join(arbol(raiz)))
|
||||
# la bandeja se queda, vacía y sin el esqueleto de la tarjeta, y no sale como hueco que borrar
|
||||
self.assertEqual(os.listdir(os.path.join(raiz, "_Entrada")), [])
|
||||
self.assertEqual((r["quedan"], O.entrada(raiz)["n"]), (0, 0))
|
||||
self.assertFalse([a for a in O.acciones(raiz)["acciones"] if a["clave"] == "entrada"])
|
||||
self.assertNotIn("_Entrada", [c["rel"] for c in O.carpetas_vacias(raiz)["carpetas"]])
|
||||
# y se deshace: cada cosa vuelve a su sitio dentro de la tarjeta
|
||||
d = O.deshacer(r["lote"])
|
||||
self.assertFalse(d["errores"], d)
|
||||
self.assertTrue(os.path.isfile(os.path.join(raiz, "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "CLIP", "C0001.MP4")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(raiz, "_Entrada", "Entrevista Dra", "C0005.MP4")))
|
||||
|
||||
def test_traer_de_una_tarjeta(self):
|
||||
"""La copia desde la tarjeta se comprueba, conserva la fecha del rodaje y no toca el origen."""
|
||||
raiz = cliente("Traer")
|
||||
mk(raiz, "2026/08- Agosto/01- Pieza/a.png", 2026, 8, 1)
|
||||
tarjeta = os.path.join(TMP, "tarjeta-sd")
|
||||
clip = mk(tarjeta, "DCIM/100MEDIA/DJI_0001.MP4", 2026, 8, 10, tam=300_000)
|
||||
mk(tarjeta, "System Volume Information/indice.dat", 2026, 8, 10)
|
||||
with open(clip, "rb") as fh:
|
||||
contenido = fh.read()
|
||||
self.assertTrue(O.plan_traer(raiz, os.path.join(raiz, "2026")).get("error")) # de dentro no se trae
|
||||
self.assertTrue(O.plan_traer(raiz, os.path.join(TMP, "no-existe")).get("error"))
|
||||
p = O.plan_traer(raiz, tarjeta)
|
||||
self.assertEqual((p["n"], p["bytes"]), (1, 300_000), p)
|
||||
self.assertTrue(p["carpeta"].startswith("Tarjeta "))
|
||||
r = O.traer(raiz, tarjeta)
|
||||
self.assertEqual((r["estado"], r["copiados"], r["errores"]), ("listo", 1, []), r)
|
||||
self.assertTrue(r["ordenado"]["ok"] and not r["ordenado"]["errores"], r["ordenado"])
|
||||
final = os.path.join(raiz, "2026", "08- Agosto", "00_Grabacion", "DJI", "DJI_0001.MP4")
|
||||
self.assertTrue(os.path.isfile(final), "\n".join(arbol(raiz)))
|
||||
with open(final, "rb") as fh:
|
||||
self.assertEqual(fh.read(), contenido)
|
||||
self.assertLess(abs(os.path.getmtime(final) - os.path.getmtime(clip)), 2, "no conservó la fecha del rodaje")
|
||||
with open(clip, "rb") as fh:
|
||||
self.assertEqual(fh.read(), contenido, "tocó el origen")
|
||||
self.assertEqual(arbol(tarjeta), [rel("DCIM", "100MEDIA", "DJI_0001.MP4"), rel("System Volume Information", "indice.dat")])
|
||||
actas = os.listdir(os.path.join(raiz, ".organilab", "entradas"))
|
||||
self.assertEqual(len(actas), 1)
|
||||
with open(os.path.join(raiz, ".organilab", "entradas", actas[0]), encoding="utf-8-sig") as fh:
|
||||
self.assertIn(rel("DCIM", "100MEDIA", "DJI_0001.MP4") + ";300000;", fh.read())
|
||||
# una copia que no queda igual que el original no se da por buena
|
||||
malo = mk(tarjeta, "DCIM/100MEDIA/DJI_0002.MP4", 2026, 8, 10, tam=50_000)
|
||||
real = O.hashlib.blake2b
|
||||
vez = []
|
||||
|
||||
class Trucado: # la segunda lectura (la de comprobación) sale distinta
|
||||
def __init__(self, **kw):
|
||||
self.h = real(**kw)
|
||||
vez.append(self)
|
||||
self.malo = len(vez) % 2 == 0
|
||||
|
||||
def update(self, b):
|
||||
self.h.update(b[::-1] if self.malo else b)
|
||||
|
||||
def digest(self):
|
||||
return self.h.digest()
|
||||
|
||||
def hexdigest(self):
|
||||
return self.h.hexdigest()
|
||||
O.hashlib.blake2b = Trucado
|
||||
try:
|
||||
estado = {"bytes": 0}
|
||||
destino = os.path.join(raiz, "_Entrada", "prueba", "DJI_0002.MP4")
|
||||
with self.assertRaises(OSError):
|
||||
O._copiar_comprobando(malo, destino, estado)
|
||||
finally:
|
||||
O.hashlib.blake2b = real
|
||||
self.assertFalse(os.path.exists(destino) or os.path.exists(destino + ".copiando"), "dejó una copia sin comprobar")
|
||||
self.assertEqual(estado["bytes"], 0)
|
||||
|
||||
def test_versiones_del_mismo_clip(self):
|
||||
"""39 clips estaban dos veces con el mismo nombre y distinta calidad, y no cabían juntos en su mes."""
|
||||
raiz = cliente("Versiones")
|
||||
oct_, may = "2025/10- Octubre/00_Grabacion/BLACKMAGIC_MP4/", "2026/05- Mayo/00_Grabacion/BLACKMAGIC_MP4/"
|
||||
buena = mk(raiz, oct_ + "A002_03020727_C001.mp4", 2025, 12, 1, tam=9000)
|
||||
peor = mk(raiz, may + "A002_03020727_C001.mp4", 2026, 7, 1, tam=3000)
|
||||
mk(raiz, oct_ + "C0001.MP4", 2025, 10, 5, tam=5000) # se llaman igual…
|
||||
mk(raiz, may + "C0001.MP4", 2026, 5, 5, tam=4000) # …pero son clips distintos: duran distinto
|
||||
# dos exportados con el mismo nombre no son un duplicado: son dos entregas de la pieza (la vertical y
|
||||
# la horizontal). No son material de cámara, así que ni se leen: `sonda` fallaría si se los pasaran.
|
||||
mk(raiz, "2026/02- Febrero/02- Liftera/Exportados/Liftera VR3 Final.mp4", 2026, 2, 20, tam=7000)
|
||||
mk(raiz, "2026/02- Febrero/01- Otra/Exportados/Liftera VR3 Final.mp4", 2026, 2, 21, tam=6000)
|
||||
p = plan(raiz)
|
||||
self.assertTrue([f for f in p["filas"] if "OJO" in f["motivo"]], "sin comparar, las dos versiones chocan en su mes")
|
||||
medidas = {buena: (12.0, 3840, 2160), peor: (12.02, 1920, 1080),
|
||||
os.path.join(raiz, oct_.replace("/", S), "C0001.MP4"): (10.0, 1920, 1080),
|
||||
os.path.join(raiz, may.replace("/", S), "C0001.MP4"): (25.0, 1920, 1080)}
|
||||
|
||||
def sonda(ruta):
|
||||
d, an, al = medidas[ruta]
|
||||
return {"codec": "h264", "perfil": "", "pix_fmt": "yuv420p", "ancho": an, "alto": al, "fps": 25.0, "dur": d,
|
||||
"kbps": 1000, "alfa": 0, "acodec": "aac", "pistas_audio": 1}
|
||||
viejos = (O.ffprobe, O.herramientas)
|
||||
O.ffprobe, O.herramientas = sonda, lambda: {"ffprobe": {"ok": True}}
|
||||
try:
|
||||
self.assertEqual(O.duplicados(raiz, minimo=0)["por_comparar"], 2)
|
||||
d = O.duplicados(raiz, minimo=0, calidades=True)
|
||||
finally:
|
||||
O.ffprobe, O.herramientas = viejos
|
||||
self.assertEqual([(g["tipo"], g["queda"], g["mueven"]) for g in d["grupos"]], [("calidad", buena, [peor])], d["grupos"])
|
||||
r = O.apartar_duplicados(raiz, d["grupos"])
|
||||
self.assertEqual((r["movimientos"], r["errores"]), (1, []), r)
|
||||
self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(buena), "Duplicados", "A002_03020727_C001.mp4")))
|
||||
self.assertTrue(os.path.isfile(os.path.join(raiz, may.replace("/", S), "C0001.MP4")), "apartó un clip que solo se llamaba igual")
|
||||
# ya no chocan: las dos van a su mes, la peor dentro de Duplicados
|
||||
p = O.Plan(O.raiz_cfg(raiz)).correr()
|
||||
self.assertFalse([f for f in p["filas"] if "OJO" in f["motivo"]], [f["motivo"] for f in p["filas"]])
|
||||
aplicar(raiz, p)
|
||||
mar = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion", "BLACKMAGIC_MP4")
|
||||
self.assertTrue(os.path.isfile(os.path.join(mar, "A002_03020727_C001.mp4")), "\n".join(arbol(raiz)))
|
||||
self.assertTrue(os.path.isfile(os.path.join(mar, "Duplicados", "A002_03020727_C001.mp4")), "\n".join(arbol(raiz)))
|
||||
|
||||
def test_orden_automatico(self):
|
||||
"""La pasada automática hace lo seguro en cada carpeta, y se frena sola cuando no debe seguir."""
|
||||
normal = cliente("Auto normal")
|
||||
for n in ("C0001.MP4", "C0002.MP4"):
|
||||
mk(normal, "2026/04- Abril/Grabacion/" + n, 2026, 4, 10)
|
||||
mk(normal, "2026/04- Abril/01 - Botox/final.mp4", 2026, 4, 12) # un retoque de nombre: dudoso, no se toca
|
||||
mucho = cliente("Auto demasiado")
|
||||
for i in range(6):
|
||||
mk(mucho, "2026/04- Abril/Grabacion/C%04d.MP4" % i, 2026, 4, 10)
|
||||
todas, ajuste = O.CONFIG["raices"], dict(O.CONFIG["automatico"])
|
||||
desconectada = {"ruta": os.path.join(TMP, "no-conectada"), "cliente": "Sin disco", "tipo": "contenido"}
|
||||
O.CONFIG["raices"] = [r for r in todas if r["cliente"] == "Auto normal"] + [desconectada]
|
||||
ensayo = os.path.join(TMP, "ensayo-tarea")
|
||||
os.makedirs(ensayo, exist_ok=True)
|
||||
os.environ["ORGANILAB_ENSAYO"] = ensayo
|
||||
try:
|
||||
res = O.automatico(origen="prueba")
|
||||
self.assertEqual([(c["cliente"], c["estado"]) for c in res["carpetas"]], [("Auto normal", "con dudas"), ("Sin disco", "sin conexion")])
|
||||
c = res["carpetas"][0]
|
||||
self.assertEqual((c["movimientos"], c["dudosos"], c["errores"]), (2, 1, []), c)
|
||||
self.assertTrue(os.path.isfile(os.path.join(normal, "2026", "04- Abril", "00_Grabacion", "SONY", "C0001.MP4")))
|
||||
self.assertTrue(os.path.isdir(os.path.join(normal, "2026", "04- Abril", "01 - Botox")), "tocó lo dudoso")
|
||||
self.assertEqual(O.estado_automatico()["ultimo"]["origen"], "prueba")
|
||||
self.assertFalse(O.AUTO["activo"])
|
||||
# con más movimientos que el límite, la carpeta no se toca y se dice por qué
|
||||
O.CONFIG["raices"] = [r for r in todas if r["cliente"] == "Auto demasiado"]
|
||||
O.CONFIG["automatico"]["limite"] = 5
|
||||
c = O.automatico(origen="prueba")["carpetas"][0]
|
||||
self.assertEqual(c["estado"], "demasiado", c)
|
||||
self.assertTrue(os.path.isfile(os.path.join(mucho, "2026", "04- Abril", "Grabacion", "C0000.MP4")), "movió pese al límite")
|
||||
# encenderlo da de alta la tarea de Windows (aquí, en una carpeta de ensayo) con la orden «automatico»
|
||||
self.assertIn("error", O.configurar_automatico(True, "25:00"))
|
||||
r = O.configurar_automatico(True, "03:30")
|
||||
self.assertTrue(r.get("ok") and r["activo"] and r["hora"] == "03:30", r)
|
||||
with open(os.path.join(ensayo, "tarea.json"), encoding="utf-8") as fh:
|
||||
tarea = json.load(fh)
|
||||
self.assertTrue(tarea["activo"] and tarea["hora"] == "03:30" and tarea["orden"].endswith(" automatico"), tarea)
|
||||
self.assertFalse(O.configurar_automatico(False, "03:30")["activo"])
|
||||
finally:
|
||||
os.environ.pop("ORGANILAB_ENSAYO", None)
|
||||
O.CONFIG["raices"] = todas
|
||||
O.CONFIG["automatico"] = ajuste
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
unittest.main(verbosity=2, exit=False)
|
||||
|
||||
@@ -237,7 +237,9 @@ def app_proyecto(ext: str):
|
||||
return EXT_A_APP.get((ext or "").lower())
|
||||
|
||||
|
||||
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"}
|
||||
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings",
|
||||
# lo que la cámara deja en la tarjeta además de los clips: índices que solo le sirven a ella
|
||||
"mediapro.xml", "sonycard.ind", "discmeta.xml", "status.bin"}
|
||||
# solo temporales y cachés que un programa regenera solo. NO se ponen aquí extensiones de datos
|
||||
# (.dat, .idx, .bak): son de plugins, subtítulos o copias que la gente guarda a propósito.
|
||||
BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk"}
|
||||
@@ -571,7 +573,7 @@ def cubeta_carpeta(nombre: str, diccionario: dict):
|
||||
FUENTE_PALABRAS = {"grabacion", "grabaciones", "grabado", "grabados", "ursa", "sony", "osmo", "dji", "blackmagic",
|
||||
"braw", "tascam", "audio", "otro", "otros", "a", "c", "camara", "material", "video", "videos",
|
||||
"mp4", "mov", "de", "la", "el", "pocket", "drone", "dron", "mic", "celular",
|
||||
"fotos", "foto", "atomos"}
|
||||
"fotos", "foto", "atomos", "tarjeta", "tarjetas", "card", "sd"}
|
||||
|
||||
# carpetas que crea la propia tarjeta de la cámara: no son el nombre de nada
|
||||
_TARJETA = {"dcim", "private", "m4root", "clip", "sub", "thmbnl", "general", "avchd", "bdmv", "stream",
|
||||
@@ -757,6 +759,9 @@ COLOR_CUBETA = {
|
||||
# Todo lo de la app en la carpeta del cliente vive aquí: los iconos de color, y lo que venga.
|
||||
CARPETA_APP = ".organilab"
|
||||
|
||||
# La bandeja de entrada: lo que se deje aquí dentro, el plan lo abre siempre y lo reparte.
|
||||
ENTRADA = "_Entrada"
|
||||
|
||||
# Qué es cada rol dicho en llano, para que el mapa pueda explicar qué cae en cada carpeta.
|
||||
ROL_QUE = {
|
||||
"camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)",
|
||||
|
||||
@@ -371,6 +371,7 @@
|
||||
<div class="o"><button class="p" id="btnElegir2" type="button">Elegir carpeta…</button> o <u>pega la ruta</u></div>
|
||||
</div>
|
||||
<div id="portadaCarpetas"></div>
|
||||
<div id="portadaAuto"></div>
|
||||
<div class="vacio-pasos">
|
||||
<div><b>1 · Escanear</b>Lee nombres, tamaños y fechas. Solo lectura.</div>
|
||||
<div><b>2 · Revisar</b>Qué pesa: BRAW, cámara, stock, proxies, basura, repetidos.</div>
|
||||
@@ -380,7 +381,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="inicioLleno">
|
||||
<div class="cab"><div><h2 class="v" id="cabTitulo">…</h2><div class="sub" id="cabSub"></div></div><div class="der"><button class="lnk" id="btnEscanear">Volver a escanear</button><button class="lnk" id="btnDesconectar" title="La carpeta deja de aparecer en la lista de la izquierda. No borra ni mueve nada del disco.">Quitar de la lista</button></div></div>
|
||||
<div class="cab"><div><h2 class="v" id="cabTitulo">…</h2><div class="sub" id="cabSub"></div></div><div class="der"><button class="lnk" id="btnPieza" title="Crea la carpeta de una pieza con el número que toca y sus subcarpetas">Nueva pieza</button><button class="lnk" id="btnTraer" title="Copia una tarjeta a la bandeja de entrada comprobando cada archivo, y lo reparte">Traer material</button><button class="lnk" id="btnEscanear">Volver a escanear</button><button class="lnk" id="btnDesconectar" title="La carpeta deja de aparecer en la lista de la izquierda. No borra ni mueve nada del disco.">Quitar de la lista</button></div></div>
|
||||
<div id="acciones" class="acc"></div>
|
||||
<div class="sig" id="siguiente" style="display:none"></div>
|
||||
<div class="row">
|
||||
@@ -406,7 +407,7 @@
|
||||
</div>
|
||||
<details class="mas"><summary>Más detalle: cámaras, meses, tipos de archivo, repetidos, dónde vive cada cliente</summary>
|
||||
<div class="row" style="margin-top:12px"><div class="box"><h3>Por fuente (de qué cámara viene)</h3><div id="fuentes"></div></div><div class="box"><h3>Material de cámara por mes</h3><div id="meses"></div></div></div>
|
||||
<div class="row"><div class="box"><h3>Por tipo de archivo</h3><div id="exts"></div></div><div class="box"><h3>Repetidos (mismo nombre y peso, o el mismo clip en MP4 y MOV)</h3><div id="dups"></div></div></div>
|
||||
<div class="row"><div class="box"><h3>Por tipo de archivo</h3><div id="exts"></div></div><div class="box"><h3>Repetidos (mismo nombre y peso, el mismo clip en MP4 y MOV, o con dos calidades)</h3><div id="dups"></div><div class="toolbar" style="margin-top:10px"><button id="btnComparar">Comparar y ver todos</button><button class="p" id="btnJuntar" disabled>Juntar en Duplicados</button><span class="mut" id="dupsResumen"></span></div></div></div>
|
||||
<div class="box" style="margin-bottom:16px"><h3>Dónde vive cada cliente (todos los discos conectados)</h3><div id="clientesDiscos" class="mut">cargando…</div></div>
|
||||
<div class="box"><h3>Discos</h3><div id="discos" class="mut">cargando…</div></div>
|
||||
</details>
|
||||
@@ -567,6 +568,9 @@
|
||||
<dt>Escanear</dt><dd>Lee nombres, tamaños y fechas de todo lo que hay dentro. <i>Cómo: recorre la carpeta sin abrir los archivos, guarda un inventario y clasifica cada cosa por su extensión, su nombre y la carpeta donde está: cámara, proyecto, exportado, stock, proxy, basura…</i></dd>
|
||||
<dt>Lista de acciones</dt><dd>Todo lo que se puede hacer ahora en esa carpeta, en orden, cada cosa con su cifra y su botón; lo ya hecho sale tachado. <i>Cómo: son consultas al inventario, por eso es instantánea.</i></dd>
|
||||
<dt>Hacer las N de una vez</dt><dd>Ejecuta seguidas las acciones que no necesitan que elijas nada: apartar basura y proxies, ordenar por fecha, ordenar lo demás, apartar stock viejo y juntar repetidos. <i>Cómo: solo entra lo que el plan da por seguro o probable; lo dudoso no entra nunca, tampoco si es material de cámara. Convertir, respaldar y borrar carpetas vacías tienen su propio botón.</i></dd>
|
||||
<dt>Nueva pieza</dt><dd>Crea la carpeta de una pieza en su mes, con el número que le toca y sus subcarpetas (Guion, Videos, Imagenes, Musica, Elements, IA, Portada, Exportado), para que todas se llamen igual. <i>Cómo: mira qué números hay ya en ese mes y pone el siguiente; si la pieza ya existe, lo dice y no crea otra. Mientras siga vacía se deshace desde el Historial, y no sale entre las carpetas vacías por borrar.</i></dd>
|
||||
<dt>Bandeja de entrada</dt><dd>Lo que se deje en la carpeta <span class="path">_Entrada</span> del cliente (una tarjeta volcada, clips sueltos, una carpeta con el nombre de la sesión) aparece en la lista como «Repartir» y va solo a su mes, a su día de rodaje y a su cámara. <i>Cómo: el plan abre siempre esa carpeta. Los índices y miniaturas que deja la cámara en la tarjeta van a <span class="path">_Para borrar</span>; lo que no sabe dónde poner se queda en la bandeja.</i></dd>
|
||||
<dt>Traer material</dt><dd>Copia una tarjeta, o cualquier carpeta de fuera, a la bandeja de entrada y la reparte. De la tarjeta no borra ni mueve nada. <i>Cómo: vuelve a leer cada archivo copiado y lo compara con el original; conserva la fecha del archivo, que es la del rodaje, y deja la lista de lo copiado con su huella en <span class="path">.organilab\entradas</span>. Si se corta, al repetirlo se retoma donde iba.</i></dd>
|
||||
<dt>Quitar de la lista</dt><dd>La carpeta deja de aparecer en el programa y, si quieres, se olvida su inventario. En el disco no se toca nada.</dd>
|
||||
</dl>
|
||||
|
||||
@@ -580,7 +584,7 @@
|
||||
<dt>Repaso de lo ya ordenado</dt><dd>El plan también mira lo que ya está en <span class="path">00_Grabacion</span>: si un clip lleva en el nombre una fecha que no es la del mes donde está, propone llevarlo al suyo. <i>Cómo: solo con fechas escritas por la cámara; la fecha de modificación no cuenta, salvo para lo que se grabó ese mismo día.</i></dd>
|
||||
<dt>Aplicar</dt><dd>Hace los movimientos marcados, siempre dentro de la carpeta del cliente. <i>Cómo: no pisa nada; si en el destino ya hay un archivo con ese nombre, lo deja donde está y lo avisa. Cada movimiento queda apuntado para poder deshacerlo.</i></dd>
|
||||
<dt>Stock viejo</dt><dd>Aparta a <span class="path">_Para borrar\stock</span> las tomas de Envato de piezas de más de 1, 2, 3, 6 o 12 meses, que se vuelven a descargar con la suscripción. <i>Cómo: las reconoce por el nombre (terminan en «-utc») o por estar en una carpeta «Elements».</i></dd>
|
||||
<dt>Repetidos</dt><dd>Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta <span class="path">Duplicados</span>. <i>Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca.</i></dd>
|
||||
<dt>Repetidos</dt><dd>Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta <span class="path">Duplicados</span>. También junta el mismo clip guardado con dos calidades: se queda el de más imagen. <i>Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca. Dos clips de cámara que se llaman igual y pesan distinto se leen: si duran lo mismo son el mismo clip, y si no, son dos clips distintos y se dejan. Dos exportados con el mismo nombre no se tocan: son dos entregas de la pieza.</i></dd>
|
||||
<dt>Carpetas vacías</dt><dd>Lista los huecos que deja ordenar y los borra. Es lo único que el programa borra, porque dentro no hay nada. <i>Cómo: cada una se comprueba en el disco justo antes; se puede deshacer, y no entra en «hacer de una vez».</i></dd>
|
||||
</dl>
|
||||
|
||||
@@ -631,7 +635,8 @@
|
||||
<h3>Sin abrir el programa</h3>
|
||||
<dl>
|
||||
<dt>Arrastrar una carpeta</dt><dd>Sobre el .exe o sobre «Organizar carpeta (arrastra aqui).bat»: la escanea, da el diagnóstico y pregunta paso a paso en una consola.</dd>
|
||||
<dt>Por comando</dt><dd><span class="path">OrganizadorLabstream.exe ayuda</span> lista los comandos: asistente, organizar, escanear, stock, respaldo, duplicados, deshacer, actualizar, instalar y desinstalar.</dd>
|
||||
<dt>Por comando</dt><dd><span class="path">OrganizadorLabstream.exe ayuda</span> lista los comandos: asistente, organizar, escanear, stock, respaldo, duplicados, deshacer, actualizar, automatico, instalar y desinstalar.</dd>
|
||||
<dt>Orden automático</dt><dd>En la portada. Encendido, el programa pone al día todas las carpetas cada día a la hora que digas: lee cada una y hace solo lo seguro. El resumen de la última pasada queda en la portada, carpeta por carpeta, y «Hacerlo ahora» lo lanza a mano. Viene apagado. <i>Cómo: lo abre una tarea del Programador de tareas de Windows, con tu sesión iniciada, que es cuando están montados los discos. Si una carpeta no se puede leer entera, o si su plan mueve más de 3.000 archivos, no la toca y lo dice. Cada cambio se deshace desde el Historial.</i></dd>
|
||||
</dl>
|
||||
|
||||
<h3>Lo que nunca hace</h3>
|
||||
@@ -787,12 +792,21 @@ function detectarCambios(){
|
||||
if (antes.asis && !ahora.asis && vistaActual() === 'carpeta') pintarAcciones().catch(() => {});
|
||||
fin('asis', 'Listo: se hizo todo lo seguro de esa carpeta.' + (ESTADO.asistente.resultado && ESTADO.asistente.resultado.errores && ESTADO.asistente.resultado.errores.length ? ` ${ESTADO.asistente.resultado.errores.length} avisos.` : ''), {texto: 'Ver historial', ir: () => irA('historial')});
|
||||
fin('resp', 'Copia de respaldo terminada.', {texto: 'Ver', ir: () => irA('respaldar')});
|
||||
if (antes.traer && !ahora.traer) {
|
||||
const t = ESTADO.traer || {}, o = t.ordenado;
|
||||
if (t.estado === 'listo') aviso(`Material traído y comprobado: ${num(t.copiados)} archivo${t.copiados !== 1 ? 's' : ''}${t.saltados ? ` (${num(t.saltados)} ya estaban)` : ''}.` + (o && !o.error ? ` Repartido: ${o.movimientos} movimiento${o.movimientos !== 1 ? 's' : ''}${o.quedan ? `; quedan ${o.quedan} en la bandeja por mirar` : ''}.` : o && o.error ? ' No se pudo repartir: ' + o.error : ' Está en la bandeja de entrada.'), 'ok', o && o.lote ? {texto: 'Deshacer el reparto', ir: () => deshacer(o.lote)} : null);
|
||||
else if (t.estado === 'cancelado') aviso('Se dejó de traer material. Lo ya copiado está en la bandeja de entrada; al volver a traerlo se retoma.', 'ok');
|
||||
else aviso(`Al traer el material, ${t.errores_n} archivo${t.errores_n !== 1 ? 's' : ''} no se pudieron copiar bien. Lo demás está en la bandeja de entrada, sin repartir.`, 'bad');
|
||||
cargarVista();
|
||||
}
|
||||
if (antes.auto && !ahora.auto) { aviso('Orden automático terminado.', 'ok'); if (!raizActual()) pintarAuto().catch(() => {}); else cargarVista(); }
|
||||
if ((antes.cola !== ahora.cola || ahora.cola) && vistaActual() === 'convertir' && !document.querySelector('#convLista input:focus')) cargarCola();
|
||||
}
|
||||
function huella(E){
|
||||
const h = {};
|
||||
E.raices.forEach(r => { if (r.progreso && r.progreso.estado === 'corriendo') h['esc:' + r.clave] = 1; if (r.analisis && r.analisis.estado === 'corriendo') h['ana:' + r.clave] = 1; });
|
||||
if (E.cola.activa) h.cola = 1; if (E.asistente.activo) h.asis = 1; if (E.respaldo.estado === 'corriendo') h.resp = 1;
|
||||
if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) h.traer = 1; if (E.automatico && E.automatico.activo) h.auto = 1;
|
||||
return h;
|
||||
}
|
||||
let SONDEO = null;
|
||||
@@ -808,6 +822,81 @@ function limpiarTablas(){
|
||||
}
|
||||
$('#btnEscanear').onclick = () => accion($('#btnEscanear'), async () => { await api('/api/escanear', {raiz: raizActual()}); await cargarEstado(); });
|
||||
|
||||
// ---------------- pieza nueva ----------------
|
||||
const MESES_LARGOS = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
$('#btnPieza').onclick = () => {
|
||||
const raiz = raizActual(), hoy = new Date(), a0 = hoy.getFullYear();
|
||||
modal('Nueva pieza', `<p>La app crea la carpeta de la pieza en su mes, con el número que le toca y sus subcarpetas, para que todas se llamen igual.</p>
|
||||
<label for="pzTitulo">Título</label><input type="text" id="pzTitulo" style="width:100%;margin-top:4px" placeholder="Por ejemplo: Ozempic face" maxlength="80">
|
||||
<div class="toolbar" style="margin-top:10px"><label>Mes <select id="pzMes">${MESES_LARGOS.map((m, i) => `<option value="${i + 1}" ${i === hoy.getMonth() ? 'selected' : ''}>${m}</option>`).join('')}</select></label><label>Año <select id="pzAnio">${[a0 - 1, a0, a0 + 1].map(a => `<option ${a === a0 ? 'selected' : ''}>${a}</option>`).join('')}</select></label></div>
|
||||
<div id="pzPrevia" class="hint" style="margin-top:10px">Escribe el título para ver dónde va a quedar.</div>`,
|
||||
() => accion($('#btnPieza'), async () => {
|
||||
const r = await api('/api/pieza', {raiz, titulo: $('#pzTitulo').value, anio: +$('#pzAnio').value, mes: +$('#pzMes').value});
|
||||
MAPA = null; PLAN = null;
|
||||
aviso(`Creada «${r.rel}» con ${r.subcarpetas.length} subcarpetas.`, 'ok', {texto: 'Abrir la carpeta', ir: () => api('/api/abrir', {ruta: r.ruta}).catch(e => aviso(e.message, 'bad'))});
|
||||
await cargarEstado(); cargarVista();
|
||||
}));
|
||||
$('#modalSi').textContent = 'Crear'; $('#modalSi').disabled = true;
|
||||
let turno = 0;
|
||||
const previa = async () => {
|
||||
const t = $('#pzTitulo').value.trim(), mio = ++turno;
|
||||
if (!t) { $('#pzPrevia').textContent = 'Escribe el título para ver dónde va a quedar.'; $('#modalSi').disabled = true; return; }
|
||||
try {
|
||||
const r = await api(`/api/pieza/previa?raiz=${encodeURIComponent(raiz)}&titulo=${encodeURIComponent(t)}&anio=${$('#pzAnio').value}&mes=${$('#pzMes').value}`);
|
||||
if (mio !== turno) return;
|
||||
$('#pzPrevia').innerHTML = r.existe ? `<span class="warn">Esa pieza ya existe: <span class="path">${esc(r.existe)}</span></span>` : `Va a quedar en <span class="path">${esc(r.rel)}</span>, con: ${r.subcarpetas.map(esc).join(' · ')}`;
|
||||
$('#modalSi').disabled = !!r.existe;
|
||||
} catch (e) { if (mio === turno) { $('#pzPrevia').innerHTML = `<span class="warn">${esc(e.message)}</span>`; $('#modalSi').disabled = true; } }
|
||||
};
|
||||
$('#pzTitulo').oninput = previa; $('#pzMes').onchange = previa; $('#pzAnio').onchange = previa;
|
||||
setTimeout(() => $('#pzTitulo').focus(), 50);
|
||||
};
|
||||
|
||||
// ---------------- bandeja de entrada: traer material de una tarjeta ----------------
|
||||
$('#btnTraer').onclick = () => {
|
||||
const raiz = raizActual();
|
||||
modal('Traer material nuevo', `<p>La app copia lo que haya en la tarjeta (o en cualquier carpeta de fuera) a la bandeja de entrada de este cliente, <b>vuelve a leer cada archivo copiado para comprobar que quedó igual</b>, y lo reparte por mes, día de rodaje y cámara. <b>De la tarjeta no borra ni mueve nada.</b></p>
|
||||
<div class="toolbar"><input type="text" id="trOrigen" placeholder="La tarjeta o la carpeta con el material" style="flex:1;min-width:220px" aria-label="De dónde traerlo"><button id="trElegir" type="button">Elegir…</button></div>
|
||||
<label for="trNombre" style="display:block;margin-top:10px">Nombre de la sesión <span class="mut">(si quieres: «Entrevista», «Evento marzo»…)</span></label><input type="text" id="trNombre" style="width:100%;margin-top:4px" maxlength="60">
|
||||
<label style="display:block;margin-top:10px"><input type="checkbox" id="trOrdenar" checked> repartirlo al terminar de copiar</label>
|
||||
<div id="trPrevia" class="hint" style="margin-top:10px">Elige de dónde traerlo.</div>
|
||||
<p class="mut" style="font-size:12.5px;margin:10px 0 0">También puedes dejar el material a mano en la bandeja: <a href="#" id="trAbrir">abrir la bandeja de entrada</a>. Lo que dejes ahí sale en la lista de la carpeta como «Repartir».</p>`,
|
||||
() => accion($('#btnTraer'), async () => {
|
||||
const r = await api('/api/entrada/traer', {raiz, origen: $('#trOrigen').value.trim(), nombre: $('#trNombre').value.trim(), ordenar: $('#trOrdenar').checked});
|
||||
aviso(`Trayendo ${num(r.n)} archivo${r.n !== 1 ? 's' : ''} (${gb(r.bytes)}). El avance está en la barra de abajo.`, 'ok');
|
||||
await cargarEstado();
|
||||
}));
|
||||
$('#modalSi').textContent = 'Traer'; $('#modalSi').disabled = true;
|
||||
const previa = async () => {
|
||||
const o = $('#trOrigen').value.trim();
|
||||
if (!o) { $('#trPrevia').textContent = 'Elige de dónde traerlo.'; $('#modalSi').disabled = true; return; }
|
||||
try {
|
||||
const r = await api('/api/entrada/plan', {raiz, origen: o, nombre: $('#trNombre').value.trim()});
|
||||
$('#trPrevia').innerHTML = `Son <b>${num(r.n)}</b> archivo${r.n !== 1 ? 's' : ''} · <b>${gb(r.bytes)}</b>. Se copian a <span class="path">_Entrada\\${esc(r.carpeta)}</span>${r.libre != null ? ` (en el disco quedan ${gb(r.libre)} libres)` : ''}.`;
|
||||
$('#modalSi').disabled = false;
|
||||
} catch (e) { $('#trPrevia').innerHTML = `<span class="warn">${esc(e.message)}</span>`; $('#modalSi').disabled = true; }
|
||||
};
|
||||
$('#trElegir').onclick = seguro(async () => { const r = await api('/api/elegir-carpeta', {para: 'traer'}); if (r.ruta) { $('#trOrigen').value = r.ruta; previa(); } });
|
||||
$('#trOrigen').onchange = previa; $('#trNombre').onchange = previa;
|
||||
$('#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')); };
|
||||
};
|
||||
|
||||
// ---------------- 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(){
|
||||
const caja = $('#portadaAuto'); if (!caja) return;
|
||||
const a = await api('/api/automatico'); const u = a.ultimo, m = a.en_marcha || {};
|
||||
const filas = u && u.carpetas ? u.carpetas.map(c => { const e = ESTADO_AUTO[c.estado] || ['', c.estado]; return `<tr><td><b>${esc(c.cliente || c.raiz)}</b></td><td><span class="${e[0]}">${esc(e[1])}</span></td><td>${c.movimientos ? num(c.movimientos) + ' movimiento' + (c.movimientos !== 1 ? 's' : '') : '—'}</td><td class="mut">${esc([c.dudosos ? c.dudosos + ' dudoso' + (c.dudosos !== 1 ? 's' : '') + ' por mirar' : '', c.detalle || '', (c.errores || []).length ? (c.errores || []).length + ' avisos' : ''].filter(Boolean).join(' · '))}</td></tr>`; }).join('') : '';
|
||||
caja.innerHTML = `<h3 style="margin:22px 0 8px">Orden automático</h3><div class="box">
|
||||
<label style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input type="checkbox" id="autoActivo" ${a.activo ? 'checked' : ''}> Poner al día todas las carpetas cada día a las <input type="time" id="autoHora" value="${esc(a.hora)}" style="width:110px"></label>
|
||||
<p class="mut" style="font-size:12.5px;margin:8px 0 0">Windows abre el programa a esa hora, lee cada carpeta y hace solo lo seguro; lo dudoso lo deja para ti, y todo se deshace desde el Historial. Hace falta el equipo encendido y tu sesión abierta. Si en una carpeta el plan mueve más de ${num(a.limite)} archivos, no la toca y lo dice.</p>
|
||||
<div class="toolbar" style="margin-top:10px"><button id="autoAhora" ${m.activo ? 'disabled' : ''}>${m.activo ? 'En marcha…' : 'Hacerlo ahora'}</button><span class="mut">${u ? `Última pasada: ${fecha(u.ts)} (${u.origen === 'programado' ? 'programada' : 'a mano'})` : 'Todavía no ha corrido ninguna vez.'}</span></div>
|
||||
${filas ? `<table style="margin-top:8px"><tbody>${filas}</tbody></table>` : ''}</div>`;
|
||||
const guardar = seguro(async () => { try { await api('/api/automatico/config', {activo: $('#autoActivo').checked, hora: $('#autoHora').value || '02:00'}); aviso($('#autoActivo').checked ? `Orden automático encendido: cada día a las ${$('#autoHora').value}.` : 'Orden automático apagado.', 'ok'); } finally { pintarAuto().catch(() => {}); } });
|
||||
$('#autoActivo').onchange = guardar; $('#autoHora').onchange = () => { if ($('#autoActivo').checked) guardar(); };
|
||||
$('#autoAhora').onclick = () => modal('Poner al día todas las carpetas', `<p>La app va a leer las <b>${(ESTADO.raices || []).length}</b> carpetas registradas, una tras otra, y a hacer en cada una solo lo seguro. Lo dudoso se queda como está. Nada se borra, y cada cambio se deshace desde el Historial.</p>`, () => accion($('#autoAhora'), async () => { await api('/api/automatico/ahora', {}); aviso('En marcha. El avance está en la barra de abajo.', 'ok'); await cargarEstado(); pintarAuto().catch(() => {}); }));
|
||||
}
|
||||
|
||||
// ---------------- barra de tareas ----------------
|
||||
function tareasDe(E){
|
||||
const T = []; const porNombre = k => (E.raices.find(r => r.clave === k) || {cliente: k}).cliente;
|
||||
@@ -819,6 +908,8 @@ function tareasDe(E){
|
||||
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
|
||||
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)});
|
||||
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
|
||||
if (E.traer && (E.traer.estado === 'copiando' || E.traer.estado === 'ordenando')) { const t = E.traer; const cop = t.estado === 'copiando'; T.push({raiz: t.raiz, q: cop ? 'Trayendo material' : 'Repartiendo lo traído', d: `${porNombre(t.raiz)} · ${num(t.hechos)} de ${num(t.total)} · ${gb(t.bytes)} de ${gb(t.bytes_total)}${t.actual ? ' · ' + t.actual.slice(-40) : ''}`, pct: cop && t.bytes_total ? Math.round(t.bytes / t.bytes_total * 100) : null, cancelar: cop ? () => api('/api/entrada/cancelar', {}) : null}); }
|
||||
if (E.automatico && E.automatico.activo) { const a = E.automatico; T.push({raiz: a.raiz, q: 'Orden automático', d: `${a.raiz ? porNombre(a.raiz) + ' · ' : ''}${a.hechas} de ${a.total} carpetas`, pct: a.total ? Math.round(a.hechas * 100 / a.total) : null}); }
|
||||
const act = E.actualizacion || {}; if (act.estado === 'descargando' || act.estado === 'verificando') T.push({q: 'Descargando versión nueva', d: `${(act.bytes / 1e6).toFixed(0)} de ${(act.total / 1e6).toFixed(0)} MB`, pct: act.pct});
|
||||
return T;
|
||||
}
|
||||
@@ -896,7 +987,8 @@ function pintarPortada(){
|
||||
// las carpetas que el programa ya conoce, para entrar en una con un clic; con la última vez que se
|
||||
// escaneó cada una. Es el «historial» de la portada: nada se abre solo.
|
||||
const caja = $('#portadaCarpetas'); const rs = ESTADO.raices || [];
|
||||
if (!rs.length) { caja.innerHTML = ''; return; }
|
||||
if (!rs.length) { caja.innerHTML = ''; $('#portadaAuto').innerHTML = ''; return; }
|
||||
pintarAuto().catch(e => console.warn(e));
|
||||
caja.innerHTML = '<h3 style="margin:22px 0 8px">Tus carpetas</h3><div class="portada">' + rs.map(r => {
|
||||
const e = r.escaneo;
|
||||
return `<div class="pc" data-r="${esc(r.clave)}" role="button" tabindex="0"><b>${esc(r.cliente)}</b><span class="path">${esc(r.ruta)}</span><span class="mut">${esc(ESTADO.tipos[r.tipo] || r.tipo)}</span><span class="mut">${e ? `${num(e.n)} archivos · ${gb(e.bytes)} · escaneada ${fecha(e.ts)}` : 'sin escanear todavía'}</span></div>`;
|
||||
@@ -923,7 +1015,7 @@ async function cargarCarpeta(){
|
||||
$('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : '');
|
||||
}
|
||||
const lotes = CARPETA.lotes || [];
|
||||
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && l.lote.startsWith('optimizar') ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
|
||||
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-')) ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
|
||||
$$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
|
||||
}
|
||||
// ---------------- lo que se puede hacer en esta carpeta ----------------
|
||||
@@ -939,7 +1031,16 @@ const IR_ACCION = {
|
||||
convertir: () => { irA('convertir'); $('#btnBuscarClips').click(); },
|
||||
respaldar: () => irA('respaldar'),
|
||||
escanear: () => $('#btnEscanear').click(),
|
||||
entrada: () => { modal('Repartir la bandeja de entrada', '<p>La app vuelve a leer la carpeta y lleva lo que hay en <span class="path">_Entrada</span> a su mes, a su día de rodaje y a su cámara.</p><p>Solo mueve lo que da por seguro: lo dudoso se queda en la bandeja para que lo mires. Nada se borra y se deshace desde el Historial.</p>', repartirEntrada); $('#modalSi').textContent = 'Repartir'; },
|
||||
};
|
||||
const repartirEntrada = seguro(async () => {
|
||||
const raiz = raizActual();
|
||||
aviso('Leyendo la carpeta y repartiendo la bandeja…', 'ok');
|
||||
const r = await api('/api/entrada/ordenar', {raiz});
|
||||
PLAN = null; MAPA = null;
|
||||
aviso(`${r.movimientos} movimiento${r.movimientos !== 1 ? 's' : ''} hecho${r.movimientos !== 1 ? 's' : ''}.` + (r.quedan ? ` Quedan ${num(r.quedan)} archivo${r.quedan !== 1 ? 's' : ''} en la bandeja: la app no supo dónde ponerlos, míralos en «Qué va a pasar».` : ' La bandeja quedó vacía.') + (r.errores.length ? ` ${r.errores.length} no se pudieron mover.` : ''), r.errores.length ? 'bad' : 'ok', r.lote ? {texto: 'Deshacer esto', ir: () => deshacer(r.lote)} : null, r.errores.join('\n'));
|
||||
await cargarEstado(); cargarVista();
|
||||
});
|
||||
async function pintarAcciones(){
|
||||
const raiz = raizActual(); if (!raiz) return;
|
||||
const d = await api('/api/carpeta/acciones?raiz=' + encodeURIComponent(raiz));
|
||||
@@ -971,6 +1072,8 @@ function queSeHizo(lote){
|
||||
if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo';
|
||||
if (lote.startsWith('dup-')) return 'Juntó material repetido';
|
||||
if (lote.startsWith('vacias-')) return 'Borró carpetas vacías';
|
||||
if (lote.startsWith('pieza-')) return 'Creó una pieza nueva';
|
||||
if (lote.startsWith('entrada-')) return 'Trajo material a la bandeja de entrada';
|
||||
return 'Ordenó carpetas y archivos';
|
||||
}
|
||||
function pasosDe(C, r){
|
||||
@@ -1070,6 +1173,24 @@ async function cargarMapa(){
|
||||
$('#exts').innerHTML = barras(m.por_ext, r => r.ext || '(sin extensión)');
|
||||
$('#dups').innerHTML = m.duplicados.ejemplos.length ? m.duplicados.ejemplos.map(d => `<div style="margin-bottom:8px"><b>${esc(d.nombre)}</b> <span class="mut">${d.tipo === 'version' ? 'dos formatos' : 'idénticos'} · ${d.copias} copias · ${gb(d.bytes)} de más</span><ul class="ejemplos" style="margin:2px 0 0 16px;padding:0"><li>se queda: ${esc(d.queda)}</li>${d.rutas.map(r => `<li>→ Duplicados: ${esc(r)}</li>`).join('')}</ul></div>`).join('') : '<span class="mut">ninguno relevante</span>';
|
||||
}
|
||||
// repetidos: la lista entera, comparando también los clips que se llaman igual y pesan distinto
|
||||
const TIPO_DUP = {identico: 'idénticos', version: 'dos formatos', calidad: 'dos calidades'};
|
||||
let DUPS = null;
|
||||
$('#btnComparar').onclick = () => accion($('#btnComparar'), async () => {
|
||||
const raiz = raizActual();
|
||||
DUPS = await api('/api/duplicados?calidades=1&raiz=' + encodeURIComponent(raiz)); DUPS.raiz = raiz;
|
||||
const rel = p => p.toLowerCase().startsWith(raiz.toLowerCase()) ? p.slice(raiz.length).replace(/^[\\/]/, '') : p;
|
||||
$('#dups').innerHTML = DUPS.grupos.length ? DUPS.grupos.slice(0, 80).map(d => `<div style="margin-bottom:8px"><b>${esc(d.nombre)}</b> <span class="mut">${TIPO_DUP[d.tipo] || d.tipo} · ${gb(d.bytes)} de más${d.detalle ? ' · ' + esc(d.detalle) : ''}</span><ul class="ejemplos" style="margin:2px 0 0 16px;padding:0"><li>se queda: ${esc(rel(d.queda))}</li>${d.mueven.map(r => `<li>→ Duplicados: ${esc(rel(r))}</li>`).join('')}</ul></div>`).join('') + (DUPS.grupos.length > 80 ? `<div class="mut">… y ${DUPS.grupos.length - 80} grupos más</div>` : '') : '<span class="mut">Nada repetido: los clips que se llaman igual duran distinto, así que son clips distintos.</span>';
|
||||
$('#dupsResumen').textContent = DUPS.grupos.length ? `${DUPS.n} de más · ${gb(DUPS.bytes)} · ${DUPS.identicos} idénticos, ${DUPS.versiones} en dos formatos, ${DUPS.calidades} con dos calidades` : '';
|
||||
$('#btnJuntar').disabled = !DUPS.grupos.length;
|
||||
});
|
||||
$('#btnJuntar').onclick = () => { if (!DUPS || DUPS.raiz !== raizActual()) return; modal('Juntar material repetido', `<p>De cada grupo se queda un archivo donde está, y los demás (<b>${DUPS.n}</b>, ${gb(DUPS.bytes)}) van a una subcarpeta <span class="path">Duplicados</span> a su lado, para mirarlos a mano.</p><p><b>No se borra nada.</b> Las copias idénticas se comparan por contenido antes de moverlas, y de dos calidades del mismo clip se queda la de más imagen. Se deshace desde el Historial.</p>`, () => accion($('#btnJuntar'), async () => {
|
||||
const r = await api('/api/duplicados/apartar', {raiz: raizActual()});
|
||||
DUPS = null; MAPA = null; PLAN = null; $('#btnJuntar').disabled = true; $('#dupsResumen').textContent = '';
|
||||
aviso(`${r.movimientos} archivo${r.movimientos !== 1 ? 's' : ''} a Duplicados.` + (r.errores.length ? ` ${r.errores.length} se dejaron donde estaban.` : ''), r.errores.length ? 'bad' : 'ok', r.lote ? {texto: 'Deshacer esto', ir: () => deshacer(r.lote)} : null, r.errores.join('\n'));
|
||||
await cargarEstado(); cargarVista();
|
||||
})); $('#modalSi').textContent = 'Juntar'; };
|
||||
|
||||
function treemap(el, items){
|
||||
el.innerHTML = ''; const W = el.clientWidth || 600, H = el.clientHeight || 300;
|
||||
const total = items.reduce((s,i) => s + i.bytes, 0) || 1;
|
||||
@@ -1371,7 +1492,7 @@ async function cargarBit(){
|
||||
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
|
||||
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
|
||||
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
|
||||
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
|
||||
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho || (l.lote || '').startsWith('entrada-') ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
|
||||
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
|
||||
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
|
||||
}
|
||||
@@ -1762,7 +1883,7 @@ const asistente = seguro(async (raiz, escanearAntes) => {
|
||||
const NOMBRE_PASO = {plan: 'pensando el plan', ordenar: 'ordenando carpetas y archivos', stock: 'apartando stock viejo', duplicados: 'juntando repetidos', analizar: 'leyendo los clips', convertir: 'convirtiendo a 10 bits', respaldo: 'copiando el respaldo', fin: 'listo'};
|
||||
|
||||
// ---------------- modal ----------------
|
||||
function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
|
||||
function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalSi').disabled = false; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape' && $('#modal').classList.contains('on')) $('#modal').classList.remove('on'); });
|
||||
$('#modal').addEventListener('click', e => { if (e.target === $('#modal')) $('#modal').classList.remove('on'); });
|
||||
|
||||
|
||||
Referencia en una nueva incidencia
Block a user