From 0895846f34a41a518e95df0f182ce3b40810eb71 Mon Sep 17 00:00:00 2001 From: Labstream Date: Tue, 15 Sep 2026 20:23:14 -0500 Subject: [PATCH] =?UTF-8?q?Plan=20por=20pasos=20en=20frases,=20sugerencias?= =?UTF-8?q?=20en=20Por=20nombrar,=20stock=20por=20antig=C3=BCedad,=20respa?= =?UTF-8?q?ldo=20por=20lotes,=20arrastre=20con=20ruta=20real,=20barra=20de?= =?UTF-8?q?=20tareas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- organizador.py | 123 +++++++++++++++++++++++- static/index.html | 231 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 279 insertions(+), 75 deletions(-) diff --git a/organizador.py b/organizador.py index a91e3ad..4f1c040 100644 --- a/organizador.py +++ b/organizador.py @@ -525,6 +525,10 @@ class Plan: "archivos": [a[0] for a in archivos] if archivos else None, "marcado": (confianza in ("alta", "media")) if marcado is None else marcado, "cosmetico": marcado is False and accion == "renombrar", + # en qué paso del recorrido se aplica: 1 limpiar (basura/proxies), 2 por fecha (años, meses, grabaciones, + # piezas), 3 el resto (exportados, proyectos, marca, música, dudosos) + "paso": "limpiar" if accion == "papelera" or cubeta in ("BASURA", "PROXY") else + "fechas" if cubeta in ("MES", "ANIO", "GRABACION", "PIEZA", "CELULAR") else "resto", }) def suelto(self, fila, cubeta, destino_rel, motivo, confianza): @@ -1081,10 +1085,65 @@ def vocabulario(raiz): t["bytes"] += c["bytes"] or 0 if len(t["rutas"]) < 4: t["rutas"].append(rel) + if tokens: + con = db() + for t in tokens.values(): + if not t["cubeta"]: + _sugerir(con, raiz, t) + con.close() out = sorted(tokens.values(), key=lambda t: (-t["bytes"], t["token"])) return out +_ROL_FRASE = {"camara": "clips de cámara", "proyecto": "proyectos de edición / diseño", "audio": "audio", + "imagen": "imágenes", "documento": "documentos", "video": "videos", "otro": "otros archivos"} + + +def _sugerir(con, raiz, t): + """Qué es probablemente esta carpeta, mirando lo que tiene dentro y a qué se parece su nombre. + Deja t["sugerencia"], t["sug_conf"], t["por_que"] y t["contenido"] (resumen legible).""" + import difflib + conteo = defaultdict(lambda: [0, 0]) + total = [0, 0] + for rel in t["rutas"][:3]: + for f in archivos_bajo(con, os.path.join(raiz, rel)): + if f["es_basura"] or f["es_proxy"]: + continue + rol = f["rol"] + k = ("camara" if rol in ("camara_raw", "camara_video", "camara_sidecar", "audio_grabado") else + "proyecto" if rol in ("proyecto", "diseno") else + "audio" if rol in ("audio",) else + "imagen" if rol in ("imagen", "foto_camara", "celular_foto") else + "documento" if rol == "documento" else + "video" if rol in ("video", "celular_video") else "otro") + conteo[k][0] += 1 + conteo[k][1] += f["bytes"] + total[0] += 1 + total[1] += f["bytes"] + orden = sorted(conteo.items(), key=lambda kv: -kv[1][1]) + t["contenido"] = " · ".join(f"{n} {_ROL_FRASE[k]}" for k, (n, b) in orden[:3]) if orden else "vacía o solo subcarpetas" + sug, conf, por_que = "", "", "" + # a) por el nombre: se parece a una palabra del diccionario + dicc = DICC["carpetas"] + cerca = difflib.get_close_matches(t["token"], list(dicc.keys()), n=1, cutoff=0.82) + if cerca and dicc[cerca[0]] not in ("CONTENEDOR", "IGNORAR"): + sug, conf, por_que = dicc[cerca[0]], "media", f"el nombre se parece a «{cerca[0]}»" + # b) por el contenido (manda si es contundente) + if total[0] >= 3 and orden: + k, (n, b) = orden[0] + frac = b / total[1] if total[1] else n / total[0] + if frac >= 0.7: + por_cont = {"camara": "GRABACION", "proyecto": "PROYECTOS", "audio": "MUSICA", "imagen": "FOTOS", + "documento": "DOCUMENTOS", "video": "EXPORTADOS"}.get(k) + if por_cont: + if sug and sug != por_cont: + por_que += f"; pero dentro casi todo son {_ROL_FRASE[k]}" + conf = "baja" + else: + sug, conf, por_que = por_cont, ("alta" if frac >= 0.9 and n >= 3 else "media"), f"dentro casi todo son {_ROL_FRASE[k]} ({round(frac * 100)} %)" + t["sugerencia"], t["sug_conf"], t["por_que"] = sug, conf, por_que + + # ========================================================================== # Mapa # ========================================================================== @@ -1359,7 +1418,7 @@ def encolar(rutas, perfil, chroma, color, raiz): return ids -COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": ""} +COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0} def set_trabajo(id_, **kw): @@ -1537,6 +1596,7 @@ def bucle_cola(): except Exception as ex: # noqa set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}") _verificar_listos([t["id"]]) + COLA["hechos"] += 1 elif lote_resolve: COLA["actual"] = lote_resolve[0]["id"] try: @@ -1545,6 +1605,7 @@ def bucle_cola(): for r in lote_resolve: set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}") _verificar_listos([r["id"] for r in lote_resolve]) + COLA["hechos"] += len(lote_resolve) else: break COLA["actual"] = None @@ -1590,6 +1651,8 @@ def iniciar_cola(): recuperar_huerfanas() COLA["activa"] = True COLA["cancelar"] = False + COLA["hechos"] = 0 + COLA["inicio"] = time.time() COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True) COLA["hilo"].start() return True @@ -1905,7 +1968,7 @@ def apartar_duplicados(raiz, grupos=None): RESPALDO = {"estado": "", "hechos": 0, "total": 0, "bytes": 0, "bytes_total": 0, "actual": "", "errores": [], "destino": "", "cancelar": False, "inicio": 0, "copiados": 0, "saltados": 0} -def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True): +def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=True, meses=0): con = db() rc = raiz_cfg(raiz) cliente = rc["cliente"] if rc else os.path.basename(raiz.rstrip("\\/")) or "Raiz" @@ -1920,6 +1983,8 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru marca_a = os.sep + "_Archivo" + os.sep items, vistos = [], set() omit_raw, omit_stock_b, omit_stock_n, convertidos = [], 0, 0, 0 + tope = limite_meses(meses) if int(meses or 0) > 0 else None # solo lo anterior a este (año, mes) + omit_reciente_n, omit_reciente_b = 0, 0 def agregar(ruta, rol, ext, bytes_, mtime): k = os.path.normpath(ruta).lower() @@ -1938,6 +2003,10 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru omit_stock_b += f["bytes"] omit_stock_n += 1 continue + if tope and not es_viejo(f, tope): + omit_reciente_n += 1 + omit_reciente_b += f["bytes"] + continue k = os.path.normpath(f["ruta"]).lower() if k in conv: sal = conv[k] @@ -1979,6 +2048,7 @@ def plan_respaldo(raiz, destino, por_tipo=True, incluir_raw=False, sin_stock=Tru "por_tipo": [{"tipo": k, **v} for k, v in sorted(por_tipo_res.items(), key=lambda kv: -kv[1]["bytes"])], "convertidos": convertidos, "raw_omitidos": {"n": len(omit_raw), "bytes": sum(x["bytes"] for x in omit_raw), "ejemplos": omit_raw[:15]}, "stock_omitido": {"n": omit_stock_n, "bytes": omit_stock_b}, + "reciente_omitido": {"n": omit_reciente_n, "bytes": omit_reciente_b, "meses": int(meses or 0)}, "muestra": [{"de": i["rel"], "a": os.path.relpath(i["destino"], destino)} for i in items[:25]], "items": items, } @@ -2128,7 +2198,14 @@ def estado_general(): con = db() esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()} cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()} + actual = None + if COLA["actual"]: + t = con.execute("SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() + if t: + actual = dict(t) con.close() + # «clip 3 de 12»: los hechos en esta tanda + el que va + los que esperan + total_tanda = COLA["hechos"] + (1 if actual else 0) + cola_n.get("pendiente", 0) esc_ci = {k.lower(): v for k, v in esc.items()} raices = [] for r in CONFIG["raices"]: @@ -2136,7 +2213,8 @@ def estado_general(): raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)}) return {"config": CONFIG, "raices": raices, "herramientas": herramientas(), "version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO, - "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "n": cola_n}, + "cola": {"activa": COLA["activa"], "actual": actual, "mensaje": COLA["mensaje"], "n": cola_n, + "hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]}, "aplicando": APLICANDO, "asistente": asistente.ESTADO, "respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])}, "cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "plantillas": PLANTILLAS} @@ -2253,6 +2331,9 @@ class Manejador(BaseHTTPRequestHandler): return if ruta == "/api/estado": return self._json(estado_general()) + if ruta == "/api/soltado": + r = soltado(q.get("nombre", "")) + return self._json({"ruta": r, "es_archivo": bool(r) and os.path.isfile(r)}) if ruta == "/api/carpeta/estado": return self._json(estado_carpeta(q["raiz"])) if ruta == "/api/mapa": @@ -2324,7 +2405,7 @@ class Manejador(BaseHTTPRequestHandler): destino = q.get("destino") or CONFIG.get("respaldo", {}).get("destino", "") if not destino: return self._json({"error": "indica la carpeta de destino del respaldo"}, 400) - plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1") + plan = plan_respaldo(raiz, destino, q.get("por_tipo", "1") == "1", q.get("incluir_raw") == "1", q.get("sin_stock", "1") == "1", int(q.get("meses", "0") or 0)) PLANES_RESPALDO[raiz] = plan CONFIG.setdefault("respaldo", {})["destino"] = destino guardar_json(CONFIG_PATH, CONFIG) @@ -2724,6 +2805,31 @@ def _asegurar_consola(): pass +SOLTADOS = {"ts": 0, "rutas": []} + + +def _al_soltar(e): + try: + rutas = [f.get("pywebviewFullPath") for f in (e.get("dataTransfer") or {}).get("files", []) if f.get("pywebviewFullPath")] + except Exception: # noqa + rutas = [] + if rutas: + SOLTADOS.update(ts=time.time(), rutas=[os.path.normpath(r) for r in rutas]) + + +def soltado(nombre, espera=2.5): + """Ruta completa de lo que se acaba de soltar en la ventana (llega por el puente WebView2). Espera un poco + porque el drop del navegador y el aviso del puente no llegan en el mismo orden.""" + fin = time.time() + espera + while time.time() < fin: + if time.time() - SOLTADOS["ts"] < 8: + for r in SOLTADOS["rutas"]: + if not nombre or os.path.basename(r.rstrip("\\/")).lower() == nombre.lower(): + return r + time.sleep(0.1) + return None + + def _ventana(url): """Ventana propia con WebView2 (Edge). Si no está disponible, se abre en el navegador.""" try: @@ -2733,6 +2839,15 @@ def _ventana(url): return False try: v = webview.create_window("OrganiLAB", url, width=1280, height=820, min_size=(960, 640), background_color="#ffffff") + + def _al_cargar(): + # WebView2 entrega la ruta completa de lo que se suelta solo por este puente (el navegador no la da): + # se guarda y la interfaz la pide en /api/soltado en cuanto ve el drop. + try: + v.dom.document.events.drop += _al_soltar + except Exception as ex: # noqa + print("sin puente de arrastre:", ex) + v.events.loaded += _al_cargar webview.start(gui="edgechromium", private_mode=False, storage_path=os.path.join(DATOS, "ventana")) return True except Exception as ex: # noqa diff --git a/static/index.html b/static/index.html index 60faf7b..d5e40fc 100644 --- a/static/index.html +++ b/static/index.html @@ -173,6 +173,32 @@ .actualizacion{border-color:var(--acc)!important;background:var(--acc2)!important;margin-bottom:18px} /* nombrar */ .nombrar{list-style:none;margin:0;padding:0} + .paso{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin-bottom:10px} + .paso .pc{display:flex;gap:12px;align-items:center;padding:12px 14px} + .paso .num{width:26px;height:26px;border-radius:50%;background:var(--acc2);color:var(--acc);font-weight:700;display:flex;align-items:center;justify-content:center;flex:none;font-size:13px} + .paso.vacio .num{background:var(--ok2);color:var(--ok)} + .paso .pc .tx{flex:1;min-width:0} + .paso .pc .tx b{font-size:14px} + .paso .pc .tx .mut{font-size:12.5px;margin-top:2px} + .paso .pc .der{display:flex;gap:8px;align-items:center;flex:none} + .paso .res{font-size:12.5px;color:var(--mut);white-space:nowrap} + .paso .detalle{border-top:1px solid var(--line2);padding:6px 14px 10px;display:none} + .paso.abierto .detalle{display:block} + .paso .fila{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:10px;align-items:start;padding:7px 0;border-bottom:1px solid var(--line2);font-size:13px} + .paso .fila:last-child{border-bottom:0} + .paso .fila .de{color:var(--mut);font-size:12px;margin-top:2px} + .paso .fila.baja .frase{color:var(--mut)} + .paso .fila .ed{border:0;background:none;color:var(--acc);cursor:pointer;font-size:12px;padding:0 4px} + .paso .grp{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);padding:10px 0 4px;font-weight:700} + #planIntro b.grande{font-size:14px} + .nombrar li{grid-template-columns:minmax(0,1fr) auto 110px} + .nombrar li .r{display:block} + .nombrar li .sg{display:block;font-size:12px;color:var(--acc);margin-top:3px;white-space:normal} + .nombrar li .sg b{display:inline} + .nombrar li .acts button{white-space:nowrap} + .nombrar li .acts{display:flex;gap:6px;align-items:center;justify-content:flex-end} + .nombrar li .acts select{max-width:230px} + .stk-grp td{background:var(--panel2);font-weight:600;font-size:12.5px} .nombrar li{display:grid;grid-template-columns:minmax(0,1fr) 260px 130px;gap:10px;align-items:center;padding:7px 0;border-bottom:1px solid var(--line);font-size:13px} .nombrar li b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .nombrar li small{color:var(--mut);white-space:nowrap} @@ -281,7 +307,7 @@
-

…

+

…

Qué hay en la carpeta

@@ -313,24 +339,21 @@
-
- - Calcula primero +
Pulsa Calcular para ver, paso a paso, qué haría la app en esta carpeta.
+
+ + - - - - - - +
-
Ahora está enPasa a (editable)Por quéSeguridadNPeso
Pulsa «Calcular».
+
-
+

La app mira qué hay dentro de cada carpeta y propone qué es. Confirma con un clic o elige otra cosa; se lo aprende para todos los discos. ?Solo se ordenan solas las carpetas reconocidas. Las que dejes sin nombrar no se mueven. Una sugerencia «segura» sale cuando casi todo el contenido es de un mismo tipo (por ejemplo, 90 % clips de cámara).

+
  • cargando…
Reglas exactas aprendidas al editar un destino en el plan
ninguna todavía
@@ -338,7 +361,8 @@

Las tomas de Envato de proyectos ya publicados sobran: se vuelven a descargar con la suscripción. ?Se detectan por el nombre (…-2026-01-22-22-34-00-utc) o por estar en una carpeta «Elements». Van a _Para borrar/stock; el borrado definitivo lo hace una persona. Se deshace desde Historial.

- + + rápido: @@ -346,7 +370,7 @@ Busca primero
- +
@@ -382,10 +406,11 @@

Respaldar

-

Copia a otro disco una versión compacta del cliente, con los clips ya convertidos en vez de los originales. ?Misma estructura de carpetas, cada una separada por tipo de medio (Video, Audio, Imagenes, Diseño, Proyectos, Documentos). Deja INDICE.csv y _MAPA.md en la raíz del respaldo. Es incremental: lo que ya está igual no se vuelve a copiar. Los originales de cámara sin convertir no se copian salvo que marques «incluir BRAW / RAW».

+

Último paso: cuando la carpeta ya está limpia, ordenada y convertida, copia a otro disco una versión compacta del cliente. Puedes ir por lotes: solo lo de más de 3, 6 o 12 meses. ?Misma estructura de carpetas, cada una separada por tipo de medio (Video, Audio, Imagenes, Diseño, Proyectos, Documentos). Deja INDICE.csv y _MAPA.md en la raíz del respaldo. Es incremental: lo que ya está igual no se vuelve a copiar. Los originales de cámara sin convertir no se copian salvo que marques «incluir BRAW / RAW».

- + + @@ -394,7 +419,7 @@
-
+

Por tipo de medio

calcula para verlo
@@ -407,7 +432,7 @@

Historial

-

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

+

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

@@ -491,7 +516,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){ $$('#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 && CARPETA && CARPETA.plan && MAPA && MAPA.escaneado) $('#btnPlan').click(); } +function subRev(s){ $$('#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(); } $$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s)); const cargarVista = seguro(async t => { t = t || vistaActual(); @@ -549,7 +574,7 @@ async function sondear(){ if (SONDEO) return; SONDEO = cargarEstado().catch(e => setInterval(sondear, 2500); $('#raiz').onchange = () => { PLAN = null; CAND = []; STOCK = []; RESP = null; MAPA = null; CARPETA = null; limpiarTablas(); cargarEstado().then(() => cargarVista()).catch(e => aviso(e.message, 'bad')); }; function limpiarTablas(){ - $('#tablaPlan tbody').innerHTML = 'Pulsa «Calcular».'; $('#planResumen').textContent = ''; $('#planAvisos').innerHTML = ''; estadoPlan(); + $('#planPasos').innerHTML = ''; $('#planAvisos').innerHTML = ''; estadoPlan(); $('#optCards').innerHTML = ''; $('#anaEstado').textContent = ''; $('#convLista').innerHTML = 'Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.'; bloquear('btnEncolar', 'x'); $('#btnEncolar').textContent = 'Convertir'; $('#tablaStock tbody').innerHTML = ''; $('#stockCaja').style.display = 'none'; $('#stockResumen').textContent = ''; bloquear('btnStockPapelera', 'Busca primero'); $('#respCards').innerHTML = ''; $('#respTipos').innerHTML = 'calcula para verlo'; $('#respRawLista').innerHTML = 'calcula para verlo'; $('#respMuestra').innerHTML = 'calcula para verlo'; $('#respEstado').textContent = ''; bloquear('btnRespCopiar', 'Calcula primero'); @@ -563,7 +588,7 @@ function tareasDe(E){ if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); } if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); } }); - if (E.cola.activa) { const n = E.cola.n || {}; T.push({raiz: (E.cola.actual || {}).raiz, q: 'Convirtiendo', d: `${E.cola.actual ? (E.cola.actual.ruta || '').split(/[\\/]/).pop() + ' · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: E.cola.actual ? Math.round(E.cola.actual.pct || 0) : null, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); } + if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); } if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); } else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.hechas * 100 / E.aplicando.total)}); if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); } @@ -605,7 +630,7 @@ function pintarIndice(){ const P = pasosDe(CARPETA, r); riel = `
${P.map((p, i) => `
${esc(p.t)}${esc(p.corto || '')}
`).join('')}
`; } - return `
${esc(r.cliente)}${esc(disco)}
${esc(e[1])}
${riel}
`; + return `
${esc(r.cliente)}${esc(disco)}
${esc(e[1])}
${riel}
`; }).join('') || '
Ninguna todavía.
'; $$('#indice .cl').forEach(el => { const ir = () => { if ($('#raiz').value === el.dataset.r) { irA('carpeta'); return; } $('#raiz').value = el.dataset.r; $('#raiz').onchange(); }; el.onclick = ir; el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ir(); } }; }); $$('#indice .x').forEach(el => el.onclick = e => { e.stopPropagation(); desconectar(el.dataset.x); }); @@ -617,9 +642,9 @@ function desconectar(clave){ const r = (ESTADO.raices || []).find(x => x.clave === clave); if (!r) return; const ocupada = tareasDe(ESTADO).some(t => (t.raiz || '').toLowerCase() === clave.toLowerCase()); if (ocupada) { aviso('Esa carpeta tiene una tarea en curso. Espera a que termine.', 'bad'); return; } - modal('Desconectar «' + r.cliente + '»', `

La carpeta ${esc(r.ruta)} desaparece de la lista de la izquierda. No se borra ni se mueve nada del disco; puedes volver a soltarla cuando quieras.

`, async () => { + modal('Quitar «' + r.cliente + '» de la lista', `

La carpeta ${esc(r.ruta)} desaparece de la lista de la izquierda. No se borra ni se mueve nada del disco; puedes volver a soltarla cuando quieras.

`, async () => { const olvidar = $('#mDatos').checked; - const listo = async (n) => { aviso(n ? `Carpeta desconectada; ${n} conversión${n > 1 ? 'es' : ''} descartada${n > 1 ? 's' : ''}.` : 'Carpeta desconectada.', 'ok'); await cargarEstado(); const s = $('#raiz'); if (s.value === clave) { s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); } else { pintarIndice(); cargarVista(); } }; + const listo = async (n) => { aviso(n ? `Carpeta quitada de la lista; ${n} conversión${n > 1 ? 'es' : ''} descartada${n > 1 ? 's' : ''}.` : 'Carpeta desconectada.', 'ok'); await cargarEstado(); const s = $('#raiz'); if (s.value === clave) { s.value = ESTADO.raices[0] ? ESTADO.raices[0].clave : ''; s.onchange(); } else { pintarIndice(); cargarVista(); } }; try { await api('/api/raiz/quitar', {raiz: clave, datos: olvidar}); await listo(0); @@ -629,9 +654,9 @@ function desconectar(clave){ // Conversiones sin terminar: se enseñan (cuáles, en qué estado) y, si ninguna está convirtiéndose ahora, // se ofrece descartarlas y desconectar de una vez. Antes solo salía un globo sin decir cuáles. apuntarAviso(e.message, c.archivos.join('\n')); - modal('No se pudo desconectar «' + r.cliente + '»', `

${esc(e.message)}.

${c.n} conversión${c.n > 1 ? 'es' : ''} sin terminar${c.por_estado.corriendo ? ` (${c.por_estado.corriendo} convirtiéndose ahora)` : ''}:

${esc(c.archivos.join('\n'))}${c.n > c.archivos.length ? `\n… y ${c.n - c.archivos.length} más` : ''}

${c.en_curso ? 'Detén la cola en Convertir a 10 bits y vuelve a intentarlo.' : 'Descartarlas las quita de la cola sin tocar ningún archivo: los originales siguen donde están, y si vuelves a conectar la carpeta puedes encolarlas otra vez.'}

`, + modal('No se pudo quitar «' + r.cliente + '»', `

${esc(e.message)}.

${c.n} conversión${c.n > 1 ? 'es' : ''} sin terminar${c.por_estado.corriendo ? ` (${c.por_estado.corriendo} convirtiéndose ahora)` : ''}:

${esc(c.archivos.join('\n'))}${c.n > c.archivos.length ? `\n… y ${c.n - c.archivos.length} más` : ''}

${c.en_curso ? 'Detén la cola en Convertir a 10 bits y vuelve a intentarlo.' : 'Descartarlas las quita de la cola sin tocar ningún archivo: los originales siguen donde están, y si vuelves a conectar la carpeta puedes encolarlas otra vez.'}

`, c.en_curso ? null : async () => { try { await api('/api/raiz/quitar', {raiz: clave, datos: olvidar, descartar_cola: true}); await listo(c.n); } catch (e2) { aviso(e2.message, 'bad', null, detalleDe(e2)); } }); - if (!c.en_curso) $('#modalSi').textContent = 'Descartar y desconectar'; + if (!c.en_curso) $('#modalSi').textContent = 'Descartar y quitar'; } }); } @@ -659,8 +684,7 @@ async function cargarCarpeta(){ $('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `
${esc(f)}
`).join('') + (d.frases.length > 3 ? `
Más en Revisar y ordenar.
` : ''); } const lotes = CARPETA.lotes || []; - $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `
${esc(fechaCorta(l.ts))}${esc(queSeHizo(l.lote))} · ${l.movimientos} mov.${l.errores ? ` · ${l.errores} avisos` : ''}${l.lote === 'optimizar' ? '' : ``}
`).join('') : 'Todavía no se ha movido nada en esta carpeta.'; - $$('#inicioLotes button').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l)); + $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `
${esc(fechaCorta(l.ts))}${esc(queSeHizo(l.lote))} · ${l.movimientos} mov.${l.errores ? ` · ${l.errores} avisos` : ''}
`).join('') : 'Todavía no se ha movido nada en esta carpeta.'; } function queSeHizo(lote){ if (!lote) return 'Cambio'; @@ -792,13 +816,22 @@ async function cargarVoc(){ const opcionesCubeta = c => `` + Object.entries(ESTADO.cubetas).map(([k,v]) => ``).join(''); function pintarNombrar(){ const f = ($('#filtroVoc').value || '').toLowerCase(); const solo = $('#soloSin').checked; const sinP = $('#sinPiezas').checked; - const rows = VOC.filter(t => (!f || t.token.includes(f) || (t.rutas||[]).join(' ').toLowerCase().includes(f)) && (!solo || !t.cubeta) && (!sinP || !t.dentro_pieza)).sort((a,b) => (!!a.cubeta - !!b.cubeta) || b.bytes - a.bytes); + const rows = VOC.filter(t => (!f || t.token.includes(f) || (t.rutas || []).join(' ').toLowerCase().includes(f)) && (!solo || !t.cubeta) && (!sinP || !t.dentro_pieza)).sort((a, b) => (!!a.cubeta - !!b.cubeta) || ((b.sug_conf === 'alta') - (a.sug_conf === 'alta')) || b.bytes - a.bytes); const sin = VOC.filter(t => !t.cubeta && !t.dentro_pieza).length; - $('#vocResumen').textContent = `${rows.length} nombres · ${sin} sin reconocer`; + const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta'); + $('#vocResumen').textContent = `${rows.length} carpetas · ${sin} sin reconocer${seguras.length ? ` · ${seguras.length} con sugerencia segura` : ''}`; const bd = $('#badgeNombrar'); bd.textContent = sin; bd.classList.toggle('cero', !sin); - $('#nombrar').innerHTML = rows.slice(0, 300).map(t => `
  • ${esc(t.ejemplo)}${t.aprendido ? ' aprendido' : ''}${esc((t.rutas||[]).slice(0,2).join(' · '))}${t.n} carpeta${t.n > 1 ? 's' : ''} · ${gb(t.bytes)}
  • `).join('') + (rows.length > 300 ? `
  • … y ${rows.length - 300} más: usa el buscador
  • ` : '') || (sin ? '
  • Nada con ese filtro.
  • ' : '
  • Todas las carpetas están reconocidas.
  • '); + bloquear('btnAceptarSug', seguras.length ? '' : 'x'); $('#btnAceptarSug').textContent = seguras.length ? `Aceptar ${seguras.length} sugerencia${seguras.length > 1 ? 's' : ''} segura${seguras.length > 1 ? 's' : ''}` : 'Aceptar las sugerencias seguras'; + $('#nombrar').innerHTML = rows.slice(0, 300).map(t => `
  • ${esc(t.ejemplo)}${t.aprendido ? ' aprendido' : ''}${t.n > 1 ? ` · ${t.n} carpetas con este nombre` : ''}${esc((t.rutas || []).slice(0, 2).join(' · '))}${t.contenido ? `Dentro: ${esc(t.contenido)}` : ''}${!t.cubeta && t.sugerencia ? `Parece ${esc(NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)} (${SEG_TXT[t.sug_conf] || t.sug_conf}): ${esc(t.por_que)}` : !t.cubeta ? `Sin pista: elige qué es, o déjala y no se moverá.` : ''}${!t.cubeta && t.sugerencia ? `` : ''}${gb(t.bytes)}
  • `).join('') + (rows.length > 300 ? `
  • … y ${rows.length - 300} más: usa el buscador
  • ` : '') || (sin ? '
  • Nada con ese filtro.
  • ' : '
  • Todas las carpetas están reconocidas.
  • '); $$('#nombrar select').forEach(s => s.onchange = () => nombrarToken(s.dataset.tok, s.value)); + $$('#nombrar [data-si]').forEach(b => b.onclick = () => nombrarToken(b.dataset.si, b.dataset.cub)); } +$('#btnAceptarSug').onclick = () => accion($('#btnAceptarSug'), async () => { + const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta'); + for (const t of seguras) { await api('/api/diccionario', {token: t.token, cubeta: t.sugerencia}); t.cubeta = t.sugerencia; t.aprendido = true; } + PLAN = null; estadoPlan(); if (CARPETA) { CARPETA.sin_nombre_n = VOC.filter(x => !x.cubeta && !x.dentro_pieza).length; pintarIndice(); } + pintarNombrar(); aviso(`${seguras.length} carpetas nombradas. Calcula el plan para verlas ordenadas.`, 'ok'); +}); const nombrarToken = seguro(async (tok, cub) => { await api('/api/diccionario', {token: tok, cubeta: cub}); const t = VOC.find(x => x.token === tok); if (t) { t.cubeta = cub; t.aprendido = !!cub; t.motivo = 'lo dijiste tú'; } @@ -812,59 +845,110 @@ function pintarReglas(reglas){ $$('#reglas button').forEach(b => b.onclick = () => accion(b, async () => { const r = reglas[+b.dataset.i]; const d = await api('/api/regla', {cliente: r.cliente, origen: r.origen, destino: null}); pintarReglas(d.reglas); PLAN = null; estadoPlan(); })); } -// ---------------- revisar: plan ---------------- +// ---------------- revisar: plan (paso a paso, en frases) ---------------- +const PASOS_PLAN = [ + {k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema (.DS_Store, Thumbs.db, cachés) y proxies se apartan en «_Para borrar». Los proxies se regeneran desde Resolve o Premiere. Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'}, + {k: 'fechas', t: 'Ordenar por año y mes', q: 'Los meses toman el nombre «03- Marzo», las grabaciones se reparten por mes y por cámara, y las piezas quedan numeradas dentro de su mes.', vacio: 'Las fechas ya están en orden.'}, + {k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música, marca y demás van a su carpeta. Lo dudoso queda sin marcar: la app no lo mueve salvo que lo marques tú.', vacio: 'Nada más que ordenar.'}, +]; +const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'}; +let PLAN_ABIERTOS = new Set(); function estadoPlan(){ const hay = !!PLAN; - ['btnMarcarAlta','btnMarcarTodo','btnCosmeticos','btnDesmarcar','btnArbol'].forEach(id => $('#' + id).disabled = !hay); - if (!hay) { bloquear('btnAplicar', 'Calcula primero'); $('#btnAplicar').textContent = 'Aplicar'; return; } - const m = PLAN.filas.filter(r => r.marcado); - bloquear('btnAplicar', PLAN.filas.length ? (m.length ? '' : 'Marca algún movimiento') : 'No hay nada que mover'); - $('#btnAplicar').textContent = m.length ? `Aplicar ${m.length} movimiento${m.length > 1 ? 's' : ''}` : 'Aplicar'; - $('#planResumen').textContent = PLAN.filas.length ? `${m.length} de ${PLAN.filas.length} marcados · ${gb(m.reduce((s,r)=>s+r.bytes,0))} · ${num(m.reduce((s,r)=>s+r.n,0))} archivos` : ''; + $('#btnArbol').disabled = !hay; + const seg = hay ? PLAN.filas.filter(r => r.marcado) : []; + bloquear('btnAplicarSeguros', hay ? (seg.length ? '' : 'x') : 'x'); $('#btnAplicarSeguros').textContent = seg.length ? `Aplicar todo lo marcado (${seg.length})` : 'Aplicar todo lo seguro'; + if (CARPETA) { CARPETA.plan = hay ? {filas: PLAN.filas.length, marcadas: seg.length, baja: PLAN.filas.filter(r => r.confianza === 'baja').length} : null; pintarIndice(); } +} +async function calcularPlan(){ + $('#planIntro').innerHTML = ' mirando la carpeta…'; + PLAN = await api('/api/plan?raiz=' + encodeURIComponent(raizActual())); + pintarPlan(); +} +$('#btnPlan').onclick = () => accion($('#btnPlan'), calcularPlan); +function motivoLlano(m){ + return (m || '').replace(/diccionario: palabra «([^»]+)»/g, 'por la palabra «$1» del nombre').replace(/diccionario: contiene «([^»]+)»/g, 'porque el nombre contiene «$1»').replace(/diccionario: «([^»]+)»/g, 'por su nombre («$1»)').replace(/regla aprendida/g, 'lo dijiste tú la vez pasada').replace(/^no reconocida: /, 'la app no reconoce el nombre: ').replace(/\(fecha de modificación\)/g, '(por la fecha de modificación: puede fallar)'); +} +function fraseDe(r){ + const nom = x => `«${esc(x.split(/[\\/]/).pop())}»`; + if (r.origen_rel === '(toda la raíz)') return `${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'} repartidos por la carpeta se apartan en «_Para borrar»`; + if (r.accion === 'papelera') return `${nom(r.origen_rel)} se aparta en «_Para borrar»`; + if (r.accion === 'renombrar') return `${nom(r.origen_rel)} pasa a llamarse ${nom(r.destino_rel)}`; + if (r.archivos) return `${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''} de ${nom(r.origen_rel)} van a «${esc(r.destino_rel)}»`; + return `${nom(r.origen_rel)} se mueve a «${esc(r.destino_rel)}»`; } -$('#btnPlan').onclick = () => accion($('#btnPlan'), async () => { $('#planResumen').innerHTML = ' calculando…'; PLAN = await api('/api/plan?raiz=' + encodeURIComponent(raizActual())); ABIERTOS = new Set(); pintarPlan(); if (CARPETA) { CARPETA.plan = {filas: PLAN.filas.length, marcadas: PLAN.filas.filter(r => r.marcado).length}; pintarIndice(); } }); -let ABIERTOS = new Set(); function pintarPlan(){ if (!PLAN) { estadoPlan(); return; } const f = ($('#filtroPlan').value || '').toLowerCase(); - $('#planAvisos').innerHTML = (PLAN.avisos||[]).map(a => `
    ${esc(a)}
    `).join(''); - const filas = PLAN.filas.filter(r => !f || (r.origen_rel + ' ' + r.destino_rel + ' ' + r.motivo).toLowerCase().includes(f)); - const grupos = {}; filas.forEach(r => (grupos[r.cubeta] = grupos[r.cubeta] || []).push(r)); - const ordenG = Object.keys(grupos).sort((a, b) => grupos[b].reduce((s,r)=>s+r.bytes,0) - grupos[a].reduce((s,r)=>s+r.bytes,0)); - let html = ''; - for (const g of ordenG) { - const rs = grupos[g].sort((a,b) => ({alta:0,media:1,baja:2}[a.confianza]-{alta:0,media:1,baja:2}[b.confianza]) || b.bytes - a.bytes); - const marc = rs.filter(r => r.marcado).length, abierto = ABIERTOS.has(g) || !!f; - const dud = rs.filter(r => r.confianza === 'baja').length; - html += `${abierto ? '▾' : '▸'}${esc(NOMBRE_CUBETA[g] || g)} ${rs.length} movimiento${rs.length > 1 ? 's' : ''}${num(rs.reduce((s,r)=>s+r.n,0))} archivos · ${gb(rs.reduce((s,r)=>s+r.bytes,0))} · ${marc} marcado${marc !== 1 ? 's' : ''}${dud ? ` · ${dud} dudoso${dud > 1 ? 's' : ''}` : ''}`; - if (abierto) for (const r of rs) html += `${esc(r.origen_rel)}${r.archivos ? `
    ${r.archivos.length} archivos: ${r.archivos.slice(0,3).map(a => esc(a.split(/[\\/]/).pop())).join(', ')}${r.archivos.length > 3 ? '…' : ''}
    ` : ''}→
    ${esc(r.accion)}
    ${esc(r.motivo)}${SEGURIDAD[r.confianza] || r.confianza}${r.n}${gb(r.bytes)}`; - } - $('#tablaPlan tbody').innerHTML = html || 'Nada que mover: la carpeta ya está en orden.'; - $$('#tablaPlan tr.grp').forEach(tr => tr.onclick = () => { const g = tr.dataset.g; if (ABIERTOS.has(g)) ABIERTOS.delete(g); else ABIERTOS.add(g); pintarPlan(); }); - $$('#tablaPlan tr[data-id]').forEach(tr => { const r = PLAN.filas.find(x => x.id === +tr.dataset.id); tr.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; estadoPlan(); }; const inp = tr.querySelector('.dest'); inp.onchange = seguro(async () => { r.destino_rel = inp.value.trim(); r.editado = true; r.marcado = true; tr.querySelector('input[type=checkbox]').checked = true; if (!r.archivos) await api('/api/regla', {cliente: PLAN.cliente, origen: r.origen_rel, destino: r.destino_rel, cubeta: r.cubeta}); estadoPlan(); }); }); + $('#planAvisos').innerHTML = (PLAN.avisos || []).map(a => `
    ${esc(a)}
    `).join(''); + const porPaso = {}; PASOS_PLAN.forEach(p => porPaso[p.k] = []); + PLAN.filas.forEach(r => (porPaso[r.paso || 'resto'] = porPaso[r.paso || 'resto'] || []).push(r)); + const tot = PLAN.filas.length, totB = PLAN.filas.reduce((a, r) => a + r.bytes, 0), dud = PLAN.filas.filter(r => r.confianza === 'baja').length; + const partes = PASOS_PLAN.filter(p => porPaso[p.k].length).map(p => `${p.t.toLowerCase()} (${porPaso[p.k].length})`); + $('#planIntro').innerHTML = tot ? `Qué va a hacer la app: ${partes.join(', ')}. En total ${tot} cambio${tot > 1 ? 's' : ''} · ${gb(totB)}. Todo se queda dentro de ${esc(PLAN.raiz)} y nada se borra.${dud ? ` ${dud} cambio${dud > 1 ? 's son dudosos y quedan' : ' es dudoso y queda'} sin marcar.` : ''} Puedes aplicar cada paso por separado, en orden.` : `La carpeta ya está en orden. No hay nada que apartar ni mover.`; + $('#planPasos').innerHTML = PASOS_PLAN.map((p, i) => { + const rs = porPaso[p.k].filter(r => !f || (r.origen_rel + ' ' + r.destino_rel + ' ' + r.motivo).toLowerCase().includes(f)).sort((a, b) => ({alta: 0, media: 1, baja: 2}[a.confianza] - {alta: 0, media: 1, baja: 2}[b.confianza]) || b.bytes - a.bytes); + const marc = rs.filter(r => r.marcado), abierto = PLAN_ABIERTOS.has(p.k) || !!f; + const vacio = !porPaso[p.k].length; + let det = ''; + if (abierto && rs.length) { + const grupos = {}; rs.forEach(r => (grupos[r.cubeta] = grupos[r.cubeta] || []).push(r)); + det = Object.keys(grupos).map(g => `
    ${esc(NOMBRE_CUBETA[g] || g)} · ${grupos[g].length}
    ` + grupos[g].map(r => `
    ${fraseDe(r)}${r.editado ? ' destino cambiado' : ''}
    ${esc(motivoLlano(r.motivo))}${r.n ? ` · ${num(r.n)} archivo${r.n > 1 ? 's' : ''} · ${gb(r.bytes)}` : ''}
    ${SEG_TXT[r.confianza] || r.confianza} ${r.archivos ? '' : ``}
    `).join('')).join(''); + } + return `
    ${vacio ? '✓' : i + 1}
    ${p.t}
    ${vacio ? p.vacio : p.q}
    ${vacio ? '' : `${rs.length} cambio${rs.length > 1 ? 's' : ''} · ${gb(rs.reduce((a, r) => a + r.bytes, 0))}${marc.length < rs.length ? ` · ${marc.length} marcado${marc.length !== 1 ? 's' : ''}` : ''}`}
    ${det}
    `; + }).join(''); + $$('#planPasos [data-ver]').forEach(b => b.onclick = () => { const k = b.dataset.ver; if (PLAN_ABIERTOS.has(k)) PLAN_ABIERTOS.delete(k); else PLAN_ABIERTOS.add(k); pintarPlan(); }); + $$('#planPasos [data-aplicar]').forEach(b => b.onclick = () => aplicarPaso(b.dataset.aplicar, b)); + $$('#planPasos .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; pintarPlan(); }; }); + $$('#planPasos [data-ed]').forEach(b => b.onclick = () => { const r = PLAN.filas.find(x => x.id === +b.dataset.ed); modal('Cambiar destino', `

    ${fraseDe(r)}

    Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.

    `, seguro(async () => { const v = $('#mDest').value.trim(); if (!v) return; r.destino_rel = v; r.editado = true; r.marcado = true; if (!r.archivos) await api('/api/regla', {cliente: PLAN.cliente, origen: r.origen_rel, destino: v, cubeta: r.cubeta}); pintarPlan(); })); }); estadoPlan(); } $('#filtroPlan').oninput = pintarPlan; -$('#btnMarcarAlta').onclick = () => { PLAN.filas.forEach(r => r.marcado = r.confianza === 'alta'); pintarPlan(); }; -$('#btnMarcarTodo').onclick = () => { PLAN.filas.forEach(r => r.marcado = true); pintarPlan(); }; -$('#btnCosmeticos').onclick = () => { PLAN.filas.forEach(r => { if (r.cosmetico) r.marcado = true; }); pintarPlan(); }; -$('#btnDesmarcar').onclick = () => { PLAN.filas.forEach(r => r.marcado = false); pintarPlan(); }; -$('#btnArbol').onclick = () => { const arbol = {}; PLAN.filas.filter(r => r.marcado).forEach(r => { const partes = r.destino_rel.split(/[\\/]/); let n = arbol; partes.forEach(p => { n[p] = n[p] || {}; n = n[p]; }); }); const lineas = []; const dibuja = (n, pref) => { const ks = Object.keys(n).sort(); ks.forEach((k,i) => { const ult = i === ks.length-1; lineas.push(pref + (ult ? '└─ ' : '├─ ') + k); dibuja(n[k], pref + (ult ? ' ' : '│ ')); }); }; dibuja(arbol, ''); modal('Cómo quedaría (solo lo marcado)', `
    ${esc(PLAN.raiz)}\n${esc(lineas.join('\n'))}
    `, null); }; -$('#btnAplicar').onclick = () => { const m = PLAN.filas.filter(r => r.marcado); modal('Aplicar los movimientos', `

    Se van a mover ${m.length} cosas (${gb(m.reduce((s,r)=>s+r.bytes,0))}, ${num(m.reduce((s,r)=>s+r.n,0))} archivos) dentro de ${esc(PLAN.raiz)}.

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

    `, () => accion($('#btnAplicar'), async () => { const destinos = {}; m.forEach(r => { if (r.editado) destinos[r.id] = r.destino_rel; }); const res = await api('/api/aplicar', {raiz: PLAN.raiz, ids: m.map(r => r.id), destinos}); PLAN = null; MAPA = null; $('#tablaPlan tbody').innerHTML = ''; estadoPlan(); $('#planResumen').innerHTML = `${res.movimientos} movimientos hechos${res.errores.length ? ` · ${res.errores.length} avisos` : ''}`; aviso(`Listo: ${res.movimientos} movimientos.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); if (res.errores.length) modal('Avisos al aplicar', `
    ${esc(res.errores.join('\n'))}
    `, null); await cargarEstado(); cargarVista(); })); $('#modalSi').textContent = 'Mover'; }; +function aplicarFilas(m, btn, titulo){ + if (!m.length) return; + modal(titulo, `

    Se mueven ${m.length} cosa${m.length > 1 ? 's' : ''} (${gb(m.reduce((a, r) => a + r.bytes, 0))}, ${num(m.reduce((a, r) => a + r.n, 0))} archivos) dentro de ${esc(PLAN.raiz)}.

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

    `, () => accion(btn, async () => { + const destinos = {}; m.forEach(r => { if (r.editado) destinos[r.id] = r.destino_rel; }); + const res = await api('/api/aplicar', {raiz: PLAN.raiz, ids: m.map(r => r.id), destinos}); + PLAN = null; MAPA = null; + aviso(`${res.movimientos} movimiento${res.movimientos !== 1 ? 's' : ''} hecho${res.movimientos !== 1 ? 's' : ''}.` + (res.errores.length ? ` ${res.errores.length} no se pudieron.` : ''), res.errores.length ? 'bad' : 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); + if (res.errores.length) modal('Algunos no se pudieron mover', `
    ${esc(res.errores.join('\n'))}
    `, null); + await cargarEstado(); await calcularPlan(); + })); + $('#modalSi').textContent = 'Aplicar'; +} +function aplicarPaso(k, btn){ const p = PASOS_PLAN.find(x => x.k === k); aplicarFilas(PLAN.filas.filter(r => (r.paso || 'resto') === k && r.marcado), btn, 'Paso: ' + p.t); } +$('#btnAplicarSeguros').onclick = () => aplicarFilas(PLAN.filas.filter(r => r.marcado), $('#btnAplicarSeguros'), 'Aplicar todo lo marcado'); +$('#btnArbol').onclick = () => { const arbol = {}; PLAN.filas.filter(r => r.marcado).forEach(r => { const partes = r.destino_rel.split(/[\\/]/); let n = arbol; partes.forEach(p => { n[p] = n[p] || {}; n = n[p]; }); }); const lineas = []; const dibuja = (n, pref) => { const ks = Object.keys(n).sort(); ks.forEach((k, i) => { const ult = i === ks.length - 1; lineas.push(pref + (ult ? '└─ ' : '├─ ') + k); dibuja(n[k], pref + (ult ? ' ' : '│ ')); }); }; dibuja(arbol, ''); modal('Cómo quedaría (solo lo marcado)', `
    ${esc(PLAN.raiz)}\n${esc(lineas.join('\n'))}
    `, null); }; // ---------------- revisar: stock viejo ---------------- +$$('#s-stock button[data-m]').forEach(b => b.onclick = () => { $('#stockMeses').value = b.dataset.m; if (STOCK.length) { STOCK.forEach(g => g.sel = g.meses >= +b.dataset.m); pintarStock(); } }); +$('#stockMeses').onchange = () => { if (STOCK.length) { STOCK.forEach(g => g.sel = g.meses >= (+$('#stockMeses').value || 0)); pintarStock(); } }; +const TRAMOS = [[12, 'Más de 12 meses'], [6, 'Entre 6 y 12 meses'], [3, 'Entre 3 y 6 meses'], [1, 'Entre 1 y 3 meses'], [0, 'Menos de 1 mes']]; $('#btnStock').onclick = () => accion($('#btnStock'), async () => { const tipos = [$('#stockVideo').checked && 'Video', $('#stockAudio').checked && 'Audio', $('#stockImg').checked && 'Imagenes'].filter(Boolean).join(','); - const d = await api(`/api/stock?raiz=${encodeURIComponent(raizActual())}&meses=${+$('#stockMeses').value || 0}&tipos=${tipos}`); - STOCK = d.grupos.map(g => ({...g, sel: true, raiz: raizActual()})); - $('#stockResumen').textContent = `${d.n} archivos · ${gb(d.bytes)} en proyectos anteriores a ${d.tope}`; + const d = await api(`/api/stock?raiz=${encodeURIComponent(raizActual())}&meses=0&tipos=${tipos}`); + const hoy = new Date(); const ahoraM = hoy.getFullYear() * 12 + hoy.getMonth(); + const min = +$('#stockMeses').value || 0; + STOCK = d.grupos.map(g => { const m = /^(\d{4})-(\d{2})/.exec(g.mes || ''); const meses = m ? ahoraM - ((+m[1]) * 12 + (+m[2]) - 1) : 0; return {...g, meses, sel: meses >= min, raiz: raizActual()}; }); + $('#stockResumen').textContent = `${d.n} archivos de stock · ${gb(d.bytes)} en total`; $('#stockCaja').style.display = ''; pintarStock(); }); function pintarStock(){ - $('#tablaStock tbody').innerHTML = STOCK.map((g,i) => `${esc(g.carpeta)}${esc(g.mes)}${g.n}${gb(g.bytes)}${g.ejemplos.map(esc).join('
    ')}`).join('') || 'Nada encontrado.'; - $$('#tablaStock input').forEach(i => i.onchange = () => { STOCK[+i.dataset.i].sel = i.checked; resumenStock(); }); resumenStock(); + const tramoDe = g => TRAMOS.find(t => g.meses >= t[0]) || TRAMOS[TRAMOS.length - 1]; + let html = ''; + TRAMOS.forEach(t => { + const gs = STOCK.map((g, i) => ({g, i})).filter(x => tramoDe(x.g) === t); + if (!gs.length) return; + const todos = gs.every(x => x.g.sel); + html += `${t[1]}${num(gs.reduce((a, x) => a + x.g.n, 0))}${gb(gs.reduce((a, x) => a + x.g.bytes, 0))}${gs.length} carpeta${gs.length > 1 ? 's' : ''}`; + html += gs.map(({g, i}) => `${esc(g.carpeta)}${esc(g.mes)}${g.n}${gb(g.bytes)}${g.ejemplos.map(esc).join('
    ')}`).join(''); + }); + $('#tablaStock tbody').innerHTML = html || 'No hay stock de Envato en esta carpeta.'; + $$('#tablaStock input[data-i]').forEach(i => i.onchange = () => { STOCK[+i.dataset.i].sel = i.checked; pintarStock(); }); + $$('#tablaStock input[data-t]').forEach(i => i.onchange = () => { const t = TRAMOS.find(x => x[0] === +i.dataset.t); STOCK.forEach(g => { if (tramoDe(g) === t) g.sel = i.checked; }); pintarStock(); }); + resumenStock(); } -function resumenStock(){ const s = STOCK.filter(g => g.sel); bloquear('btnStockPapelera', STOCK.length ? (s.length ? '' : 'Marca alguna carpeta') : 'Busca primero'); } +function resumenStock(){ const s = STOCK.filter(g => g.sel); bloquear('btnStockPapelera', STOCK.length ? (s.length ? '' : 'Marca alguna carpeta') : 'Busca primero'); $('#btnStockPapelera').textContent = s.length ? `Apartar ${s.length} carpeta${s.length > 1 ? 's' : ''} (${gb(s.reduce((a, g) => a + g.bytes, 0))})` : 'Apartar a _Para borrar/stock'; } $('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g.raiz === raizActual()); const rutas = s.flatMap(g => g.rutas); if (!rutas.length) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad'); modal('Apartar stock de Envato', `

    ${rutas.length} archivos (${gb(s.reduce((a,g)=>a+g.bytes,0))}) se mueven a _Para borrar/stock dentro de la carpeta del cliente. Nada se borra; se deshace desde Historial.

    `, () => accion($('#btnStockPapelera'), async () => { const r = await api('/api/stock/papelera', {raiz: raizActual(), rutas}); aviso(`${r.movimientos} archivos de stock apartados.`, 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); STOCK = []; pintarStock(); $('#stockCaja').style.display = 'none'; MAPA = null; await cargarEstado(); })); $('#modalSi').textContent = 'Apartar'; }; // ---------------- convertir ---------------- @@ -947,10 +1031,10 @@ function pintarRespaldo(){ $('#btnRespCalc').onclick = () => accion($('#btnRespCalc'), async () => { const dest = $('#respDestino').value.trim(); if (!dest) throw new ApiError('Indica la carpeta de destino'); $('#respEstado').innerHTML = ' calculando…'; - RESP = await api(`/api/respaldo/plan?raiz=${encodeURIComponent(raizActual())}&destino=${encodeURIComponent(dest)}&por_tipo=${$('#respTipo').checked ? 1 : 0}&sin_stock=${$('#respSinStock').checked ? 1 : 0}&incluir_raw=${$('#respRaw').checked ? 1 : 0}`); + RESP = await api(`/api/respaldo/plan?raiz=${encodeURIComponent(raizActual())}&destino=${encodeURIComponent(dest)}&por_tipo=${$('#respTipo').checked ? 1 : 0}&sin_stock=${$('#respSinStock').checked ? 1 : 0}&incluir_raw=${$('#respRaw').checked ? 1 : 0}&meses=${$('#respMeses').value}`); RESP.raiz = raizActual(); $('#respEstado').textContent = `se copiaría a ${RESP.base}`; - $('#respCards').innerHTML = [[num(RESP.n), 'archivos a copiar'], [gb(RESP.bytes), 'peso del respaldo'], [RESP.convertidos, 'clips ya en H.265 10 bits'], [gb(RESP.raw_omitidos.bytes), `originales de cámara NO copiados (${RESP.raw_omitidos.n})`], [gb(RESP.stock_omitido.bytes), `stock de Envato omitido (${RESP.stock_omitido.n})`]].map(([b,s]) => `
    ${b}${s}
    `).join(''); + $('#respCards').innerHTML = [[num(RESP.n), 'archivos a copiar'], [gb(RESP.bytes), 'peso del respaldo'], [RESP.convertidos, 'clips ya en H.265 10 bits'], [gb(RESP.raw_omitidos.bytes), `originales de cámara NO copiados (${RESP.raw_omitidos.n})`], [gb(RESP.stock_omitido.bytes), `stock de Envato omitido (${RESP.stock_omitido.n})`]].concat(RESP.reciente_omitido && RESP.reciente_omitido.meses ? [[gb(RESP.reciente_omitido.bytes), `de menos de ${RESP.reciente_omitido.meses} meses, no se copia aún (${num(RESP.reciente_omitido.n)})`]] : []).map(([b,s]) => `
    ${b}${s}
    `).join(''); $('#respTipos').innerHTML = `${RESP.por_tipo.map(t => ``).join('')}
    ${esc(t.tipo)}${num(t.n)}${gb(t.bytes)}
    `; $('#respRawLista').innerHTML = RESP.raw_omitidos.n ? `
      ${RESP.raw_omitidos.ejemplos.map(x => `
    • ${esc(x.rel)} · ${gb(x.bytes)}
    • `).join('')}${RESP.raw_omitidos.n > 15 ? `
    • … y ${RESP.raw_omitidos.n - 15} más
    • ` : ''}
    ` : 'Ninguno: todo lo de cámara ya tiene su versión 10 bits (o se incluye tal cual).'; $('#respMuestra').innerHTML = RESP.muestra.map(m => `${esc(m.de)} → ${esc(m.a)}`).join('
    '); @@ -974,8 +1058,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 ? `
    Avisos recientes
    ` + av.slice(0, 20).map(a => `
    ${hora(a.ts)}${esc(a.texto)}${!solo && a.raiz ? ` · ${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}` : ''}${a.detalle ? `
    detalle
    ${esc(a.detalle)}
    ` : ''}
    `).join('') : ''; - $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
    ${esc(titulo(k))}
    ` + dias[k].map(l => `
    ${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
    ejemplos
    ${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
    ')}
    ` : ''}
    ${l.deshecho || l.lote === 'optimizar' ? '' : ``}
    `).join('')).join('') || '
    Todavía no se ha movido nada.
    '); - $$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l)); + $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
    ${esc(titulo(k))}
    ` + dias[k].map(l => `
    ${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
    ejemplos
    ${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
    ')}
    ` : ''}
    `).join('')).join('') || '
    Todavía no se ha movido nada.
    '); const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); }; } $('#bitSoloRaiz').onchange = () => cargarBit().catch(e => aviso(e.message, 'bad')); @@ -1025,6 +1108,12 @@ document.addEventListener('drop', seguro(async e => { const texto = e.dataTransfer.getData('text'); if (!nombre && texto && /^[A-Za-z]:\\/.test(texto.trim())) return proponerRaiz(texto.trim()); if (!nombre) return aviso('Suelta una carpeta (no un archivo).', 'bad'); + // dentro de la ventana de OrganiLAB (WebView2) la ruta completa llega por el puente: no hay que buscar + if (window.pywebview || (window.chrome && window.chrome.webview)) { + modal('Leyendo la carpeta…', `

    «${esc(nombre)}»

    `, null); + const s = await api('/api/soltado?nombre=' + encodeURIComponent(nombre)); + if (s.ruta) return proponerRaiz(s.es_archivo ? s.ruta.replace(/[\/][^\/]*$/, '') : s.ruta); + } modal('Buscando la carpeta…', `

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

    `, null); const d = await api('/api/buscar-carpeta?nombre=' + encodeURIComponent(nombre)); if (!d.coincidencias.length) return modal('No la encontré', `

    No hay ninguna carpeta llamada ${esc(nombre)} en los tres primeros niveles de los discos. Pega la ruta completa:

    `, () => proponerRaiz($('#rutaPegada').value.trim()));