From 41155cd8344128298521068be691b2a3d1627a7a Mon Sep 17 00:00:00 2001 From: Labstream Date: Wed, 14 Oct 2026 07:43:50 -0500 Subject: [PATCH] =?UTF-8?q?Inicio=20como=20lista=20con=20=E2=9C=93:=20lo?= =?UTF-8?q?=20hecho=20se=20queda=20en=20su=20sitio,=20con=20cu=C3=A1ndo=20?= =?UTF-8?q?y=20qu=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Al terminar algo (elegir el tipo de trabajo, apartar stock, juntar repetidos, la bandeja, convertir) la ficha desaparecía sin decir nada y no se sabía por dónde se iba. Ahora cada paso hecho se queda con su ✓ y «Hecho hoy a las 07:41: …», arriba se cuentan los hechos, y lo que acaba de pasar a hecho se ilumina. Aplicar el plan apunta qué pasos entraron; elegir el tipo y nombrar una carpeta quedan en la bitácora. «Basura y proxies» deja de pedirse cuando lo que queda es lo de «No tocar» o lo que va sin marcar. Co-Authored-By: Claude Opus 5.5 --- organizador.py | 102 ++++++++++++++++++++++++++++++++++++++++++-- prueba_funcional.py | 4 +- static/index.html | 21 ++++++--- 3 files changed, 115 insertions(+), 12 deletions(-) diff --git a/organizador.py b/organizador.py index 385559c..0368223 100644 --- a/organizador.py +++ b/organizador.py @@ -42,7 +42,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "3.7.0" +VERSION = "3.7.1" BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) # Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es @@ -2162,7 +2162,9 @@ def aplicar(raiz, filas): if os.path.normpath(base).lower() != os.path.normpath(raiz).lower(): quitar_vacias(base, raiz) actualizar_inventario(movidos) - bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores}) + # qué pasos del plan entraron (limpiar, fechas, resto, banco): Inicio les pone su ✓ con esto + pasos = dict(Counter(f.get("paso") or "resto" for f in filas)) + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": hechas, "movimientos": len(movidos), "errores": errores, "pasos": pasos}) return {"lote": lote, "filas": hechas, "movimientos": len(movidos), "errores": errores} @@ -6011,6 +6013,62 @@ def _ultimo_orden(raiz): return None +def _nombre_tipo(t): + return (R.TIPOS_CLIENTE.get(t) or t or "").split(" · ")[0] + + +# el orden de la lista de Inicio: lo hecho se queda en su sitio, no se va al final ni desaparece +_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "fechas", "resto", "banco", "vacias", "stock", + "repetidos", "convertir", "respaldar"] + + +def _historial_carpeta(raiz): + """Lo que ya se hizo en esta carpeta, por acción de Inicio: la última vez y qué fue. Con esto cada paso + hecho sale con su ✓ en vez de desaparecer de la lista (antes el stock, los repetidos, la bandeja, el + tipo de trabajo o la conversión, al terminarlos, simplemente se iban, y no se sabía por dónde se iba).""" + ent = leer_bitacora(20000) + rl = raiz.lower() + deshechos = {e.get("de_lote") for e in ent if e.get("op") == "deshacer"} + h = {} + + def pon(clave, ts, texto, n=1): + x = h.setdefault(clave, {"ts": 0, "texto": "", "veces": 0}) + x["veces"] += n + if (ts or 0) >= x["ts"]: + x["ts"], x["texto"] = ts or 0, texto + for e in ent: + op, l, ts = e.get("op"), e.get("lote") or "", e.get("ts") + if (e.get("raiz") or "").lower() != rl and rl not in [x.lower() for x in (e.get("raices") or [])]: + continue + if l and l in deshechos: + continue + if op == "tipo": + pon("tipo", ts, f"elegiste «{_nombre_tipo(e.get('tipo'))}»") + elif op == "nombrar": + pon("nombrar", ts, f"dijiste qué es «{e.get('ejemplo') or e.get('token')}»") + elif op == "respaldo": + pon("respaldar", ts, f"{_plural(e.get('copiados') or 0, 'archivo copiado', 'archivos copiados')} a {e.get('a') or 'el respaldo'}") + elif op == "tanda": + pon("convertir", ts, _plural(e.get("clips") or 0, "clip convertido", "clips convertidos")) + elif op == "fin": + m = e.get("movimientos") or 0 + if l.startswith("stock-"): + pon("stock", ts, _plural(m, "archivo apartado", "archivos apartados") + " a «_Para borrar\\stock»") + elif l.startswith("dup-"): + pon("repetidos", ts, _plural(m, "copia juntada", "copias juntadas") + " en «Duplicados»") + elif l.startswith("vacias-"): + pon("vacias", ts, _plural(m, "carpeta vacía borrada", "carpetas vacías borradas")) + elif l.startswith("entrada-"): + pon("entrada", ts, _plural(m, "archivo traído", "archivos traídos")) + elif l.startswith(("optimizar", "descartados-")): + pon("convertir", ts, _plural(m, "original reemplazado", "originales reemplazados")) + elif not l.startswith(("pieza-", "colores-", "tanda-", "respaldo")): + # aplicar el plan: los pasos que entraron (los lotes de antes de la 3.7.1 no lo apuntaban) + for p, n in (e.get("pasos") or {"limpiar": 0, "fechas": 0, "resto": 0}).items(): + pon(p, ts, _plural(n, "cambio aplicado", "cambios aplicados") if n else _plural(m, "movimiento", "movimientos")) + return h + + def _cuando(ts): """«hoy a las 05:02», «ayer a las 23:10», «el 3 de octubre a las 11:40».""" if not ts: @@ -6086,7 +6144,18 @@ def acciones(raiz): fila = con.execute("SELECT COUNT(*) n, COALESCE(SUM(bytes),0) b FROM archivos WHERE raiz = ? " "AND (es_basura = 1 OR es_proxy = 1)" + en_papelera, [raiz] + args_pap).fetchone() vieja = con.execute("SELECT n, bytes FROM carpetas WHERE ruta = ?", (os.path.join(raiz, R.PAPELERAS[1]),)).fetchone() - if fila["n"]: + plan_l = PLANES.get(raiz) + marc_l = [f for f in plan_l["filas"] if f.get("paso") == "limpiar" and f.get("marcado")] if plan_l else None + sin_l = [f for f in plan_l["filas"] if f.get("paso") == "limpiar" and not f.get("marcado")] if plan_l else [] + if fila["n"] and marc_l is not None and not marc_l: + # lo que queda no lo aparta la app sola (está en «No tocar», o es un proxy sin su original): está hecho + # lo seguro; queda mirar + añadir(clave="limpiar", titulo="Basura y proxies", boton="Ver", estado="espera", + que="Lo seguro ya está apartado. Lo que queda no se aparta sin que lo marques tú.", + cifra=f"{_plural(len(sin_l), 'cambio sin marcar', 'cambios sin marcar')} · {_gb(sum(f['bytes'] for f in sin_l))}", + detalle=("Hay proxies sin su original: pueden ser la única copia." if any("OJO" in f["motivo"] for f in sin_l) else "") + + _no_tocar_dentro(con, raiz), ir="plan", paso="limpiar") + elif fila["n"]: añadir(clave="limpiar", titulo="Apartar basura y proxies", boton="Ver y apartar", que="Archivos de sistema y proxies, que Resolve y Premiere regeneran solos.", cifra=f"{_plural(fila['n'], 'archivo', 'archivos')} · {_gb(fila['b'])}", @@ -6114,7 +6183,7 @@ def acciones(raiz): # sin sumar bytes: una carpeta sin reconocer dentro de otra sin reconocer contaría dos veces # el mismo material, y salían totales mayores que el propio disco con_sug = [t for t in sin if t.get("sugerencia")] - añadir(clave="nombrar", titulo="Decir qué son " + _plural(len(sin), "carpeta", "carpetas"), boton="Nombrarlas" if len(sin) > 1 else "Nombrarla", + añadir(clave="nombrar", titulo=("Decir qué son " + _plural(len(sin), "carpeta", "carpetas")) if len(sin) > 1 else "Decir qué es una carpeta", boton="Nombrarlas" if len(sin) > 1 else "Nombrarla", que="Sin esto la app no las mueve: se quedan donde están.", cifra=_plural(len(sin), "carpeta", "carpetas") + (f" · {len(seguras)} de un clic" if seguras else ""), detalle=(f"La app propone qué son {len(con_sug)} de ellas mirando lo que tienen dentro" @@ -6298,6 +6367,27 @@ def acciones(raiz): ir="respaldar") con.close() + hist = _historial_carpeta(raiz) + hay = {a["clave"] for a in out} + for clave, titulo, que in (("tipo", "Cómo se ordena esta carpeta", f"Como «{_nombre_tipo(r.get('tipo', 'contenido'))}», igual que su cliente."), + ("entrada", "Bandeja de entrada", "Vacía: lo que entró ya está repartido."), + ("stock", "Stock de Envato viejo", "No queda stock viejo por apartar."), + ("repetidos", "Material repetido", "No quedan copias de más por juntar."), + ("convertir", "Clips de cámara", "No quedan clips que compense convertir.")): + if clave in hist and clave not in hay: + añadir(clave=clave, titulo=titulo, estado="hecho", que=que, cifra="", detalle="") + for a in out: + x = hist.get(a["clave"]) + if not x or "Hecho " in (a.get("detalle") or ""): + continue + cuando = _cuando(x["ts"]) + if a["clave"] == "nombrar" and x["veces"] > 1: + a["hecho"] = f"Ya nombraste {x['veces']}; la última {cuando}." + elif a["estado"] == "hecho": + a["hecho"] = f"Hecho {cuando}: {x['texto']}." + else: + a["hecho"] = f"Última vez {cuando}: {x['texto']}." + out.sort(key=lambda a: _ORDEN_ACCIONES.index(a["clave"]) if a["clave"] in _ORDEN_ACCIONES else 99) # «lo seguro» son las acciones que se pueden hacer sin elegir nada: no entran convertir ni respaldar auto = [a["clave"] for a in out if a["estado"] == "toca" and a["clave"] in ("limpiar", "fechas", "resto", "stock", "repetidos")] if (esc["reglas"] or 0) < R.VERSION_REGLAS: @@ -6748,9 +6838,11 @@ class Manejador(BaseHTTPRequestHandler): tipo = d.get("tipo") if not rc or tipo not in PLANTILLAS: return self._json({"error": "carpeta o tipo de trabajo desconocido"}, 400) + antes = rc.get("tipo", "contenido") rc["tipo"] = tipo CONFIG.setdefault("clientes", {}).setdefault(rc["cliente"], {})["tipo"] = tipo guardar_json(CONFIG_PATH, CONFIG) + bitacora({"op": "tipo", "raiz": k, "tipo": tipo, "antes": antes}) olvidar_plan(k) return self._json({"ok": True, "tipo": tipo}) if ruta == "/api/clientes": @@ -6785,6 +6877,8 @@ class Manejador(BaseHTTPRequestHandler): DICC["carpetas"][tok] = cub DICC["aprendido"].append({"ts": time.time(), "token": tok, "cubeta": cub}) guardar_json(DICC_PATH, DICC) + if cub and d.get("raiz"): # para el ✓ de «Decir qué son» en Inicio + bitacora({"op": "nombrar", "raiz": _clave(d["raiz"]), "token": tok, "cubeta": cub, "ejemplo": d.get("ejemplo") or tok}) return self._json({"ok": True}) if ruta == "/api/regla": with LOCK: diff --git a/prueba_funcional.py b/prueba_funcional.py index 2037ffc..e0329f0 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -209,7 +209,9 @@ def p_tipo_y_pesa(): assert c == 200, r e = get('/api/estado') assert e['config']['clientes']['Cliente Smoke']['tipo'] == 'contenido' and raiz_estado()['tipo'] == 'contenido' - assert 'tipo' not in [x['clave'] for x in get('/api/carpeta/acciones' + q(raiz=RAIZ))['acciones']] + # y la ficha se queda, hecha, con lo que se eligió y cuándo: no desaparece + t = {x['clave']: x for x in get('/api/carpeta/acciones' + q(raiz=RAIZ))['acciones']}['tipo'] + assert t['estado'] == 'hecho' and 'Piezas mes a mes' in (t.get('hecho') or ''), t return '«Qué pesa» entra hasta %d niveles bajo 2026; el tipo de la carpeta y el del cliente se igualan' % d2['niveles'] diff --git a/static/index.html b/static/index.html index f8afd3b..7f95edf 100644 --- a/static/index.html +++ b/static/index.html @@ -260,6 +260,11 @@ .acc{display:flex;flex-direction:column;gap:8px;margin-bottom:18px} .ac{display:grid;grid-template-columns:26px minmax(0,1fr) auto;gap:14px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px} .ac.hecho{background:transparent;border-style:dashed;opacity:.62} + .ac .hc{margin-top:4px;font-size:12.5px;font-weight:600;color:var(--ok)} + .ac.hecho .hc{opacity:1} + .ac.recien{opacity:1;animation:recien 3s ease-out} + @keyframes recien{0%{background:var(--ok2);border-color:var(--ok);border-style:solid}70%{background:var(--ok2)}100%{background:transparent}} + .cabsec .nhechas{margin-left:10px;color:var(--ok);text-transform:none;letter-spacing:0;font-weight:600} .ac.espera{opacity:.72} .ac .ico{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;flex:none;background:var(--acc2);color:var(--acc)} .ac.hecho .ico{background:var(--ok2);color:var(--ok)} @@ -1225,7 +1230,7 @@ const IR_ACCION = { await api('/api/raiz/tipo', {raiz: raizActual(), tipo: t}); PLAN = null; MAPAE = null; aviso(`Listo: la carpeta y el cliente se ordenan como «${(ESTADO.tipos[t] || t).split(' · ')[0]}».`, 'ok'); - await cargarEstado(); cargarVista(); + await cargarEstado(); await pintarAcciones(); cargarVista(); })); $('#modalSi').textContent = 'Usar este'; }, @@ -1251,16 +1256,18 @@ async function pintarAcciones(){ const raiz = raizActual(); if (!raiz) return; const d = await api('/api/carpeta/acciones?raiz=' + encodeURIComponent(raiz)); if (raizActual() !== raiz) return; // cambió de carpeta mientras llegaba + // lo que acaba de pasar a hecho se ilumina un momento: así se ve que el cambio se hizo + const previas = $('#acciones').dataset.raiz === raiz ? Object.fromEntries(ACCIONES.map(a => [a.clave, a.estado])) : {}; ACCIONES = d.acciones || []; $('#acciones').dataset.raiz = raiz; // de qué carpeta es lo que se ve: así se sabe cuándo decir «leyendo» - const quedan = ACCIONES.filter(a => a.estado === 'toca').length; + const quedan = ACCIONES.filter(a => a.estado === 'toca').length, hechas = ACCIONES.filter(a => a.estado === 'hecho').length; const auto = d.automatico || [], asis = d.asistente; const corriendo = asis && asis.activo; const cab = corriendo ? `
Trabajando en esta carpeta${esc(NOMBRE_PASO[asis.paso] || asis.paso || '')} · ${asis.hechas || 0} de ${asis.total || 0}
` - : `
${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día' + (d.ultimo ? ` · último cambio ${esc(fechaCorta(d.ultimo.ts))}` : '')}${auto.length > 1 ? '' : ''}
`; + : `
${quedan ? `Lo que se puede hacer ahora · ${quedan}` : 'Esta carpeta está al día' + (d.ultimo ? ` · último cambio ${esc(fechaCorta(d.ultimo.ts))}` : '')}${hechas ? `✓ ${hechas} hecho${hechas !== 1 ? 's' : ''}` : ''}${auto.length > 1 ? '' : ''}
`; $('#acciones').innerHTML = cab + - ACCIONES.map((a, i) => `
${a.calculando ? '' : a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}
${esc(a.titulo)}
${esc(a.que)}
${a.detalle ? `
${esc(a.detalle)}
` : ''}
${a.cifra ? `${esc(a.cifra)}` : ''}${a.boton ? `` : ''}
`).join(''); + ACCIONES.map((a, i) => `
${a.calculando ? '' : a.estado === 'hecho' ? '✓' : a.estado === 'espera' ? '·' : i + 1}
${esc(a.titulo)}
${esc(a.que)}
${a.detalle ? `
${esc(a.detalle)}
` : ''}${a.hecho ? `
✓ ${esc(a.hecho)}
` : ''}
${a.cifra ? `${esc(a.cifra)}` : ''}${a.boton ? `` : ''}
`).join(''); $$('#acciones [data-a]').forEach(b => b.onclick = () => { const a = ACCIONES[+b.dataset.a]; (IR_ACCION[a.ir] || (() => {}))(a.paso, a); }); // se acaba de mover algo y el programa está comprobando qué queda: se vuelve a preguntar en un momento if (d.calculando) setTimeout(() => { if (raizActual() === raiz && vistaActual() === 'carpeta') cargarCarpeta().catch(() => {}); }, 1500); @@ -1484,13 +1491,13 @@ function pintarNombrar(){ } $('#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; } + for (const t of seguras) { await api('/api/diccionario', {token: t.token, cubeta: t.sugerencia, raiz: raizActual(), ejemplo: t.ejemplo}); 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ú'; } + const t = VOC.find(x => x.token === tok); + await api('/api/diccionario', {token: tok, cubeta: cub, raiz: raizActual(), ejemplo: t ? t.ejemplo : tok}); if (t) { t.cubeta = cub; t.aprendido = !!cub; t.motivo = 'lo dijiste tú'; } PLAN = null; estadoPlan(); if (CARPETA) { CARPETA.sin_nombre_n = VOC.filter(x => !x.cubeta && !x.dentro_pieza).length; pintarIndice(); } pintarNombrar(); if (cub) aviso(`«${t ? t.ejemplo : tok}» ahora es ${NOMBRE_CUBETA[cub] || cub}. Calcula el plan de nuevo para verlo.`, 'ok');