Se añadió un nuevo script `x2.py` para gestionar un editor web de reglas de embellecimiento en `beautify_rules.json`, permitiendo operaciones CRUD y validación de reglas. Se actualizaron las descripciones en `scripts_description.json` y se documentaron los cambios en `MemoriaDeEvolucion.md`. Además, se mejoró la lógica de procesamiento de imágenes en `email_parser.py` para conservar un ancho máximo de 800px al incrustar imágenes en Markdown.

This commit is contained in:
Miguel 2025-08-08 16:59:13 +02:00
parent fc85347a43
commit 9d9a1bba24
12 changed files with 2980 additions and 223 deletions

View File

@ -56,3 +56,29 @@ Salida Markdown: Escribe el índice seguido del contenido formateado en Markdown
- Impacto:
- El `.md` resultante muestra las imágenes embebidas (Obsidian) desde rutas absolutas bajo `.../adjuntos/cronologia/...`.
- Se preserva el listado de adjuntos como enlaces `[[archivo]]`.
## 2025-08-08 — Editor web para `beautify_rules.json`
- Decisión:
- Crear un editor web simple (Flask) para visualizar/editar `config/beautify_rules.json` con CRUD básico y respaldo automático.
- Cambios:
- `x2.py`: servidor Flask con endpoints `/api/rules` (GET/POST), `/api/meta`, `/api/heartbeat` y `/_shutdown`. Valida estructura de reglas (campos requeridos, `action`/`type` permitidos) y genera backup con timestamp antes de guardar. Implementa cierre limpio y auto-cierre por inactividad (latido del cliente cada 10s; timeout configurable vía `INACTIVITY_TIMEOUT_SECONDS`, por defecto 60s).
- `templates/index.html`, `static/app.js`, `static/style.css`: interfaz para listar, reordenar por `priority`, agregar/eliminar reglas y editar campos. Incluye vista "Avanzado" con JSON crudo.
- UI: botón "duplicar" regla; ayuda contextual en el modal que explica cada `action` y sus efectos.
- Impacto:
- Edición segura de reglas con validación previa y backups incrementales.
- El servidor del editor se cierra automáticamente si no hay navegador activo.
- Facilita ajustar reglas sin tocar el archivo manualmente.
## 2025-08-08 — Mejora de persistencia de comentarios y UX de textareas
- Decisión:
- Asegurar que los comentarios de las reglas (`__comment`) se persistan siempre al guardar, aun cuando el usuario edite el JSON crudo.
- Mejorar la ergonomía visual inicial reduciendo la altura inicial de los campos "Comentario" y "Pattern" a 2 líneas, expandiéndose solo al escribir.
- Cambios:
- `static/app.js`:
- `saveAll()`: se fusiona el JSON avanzado si es válido, pero las `rules` provienen siempre del estado actual de la UI (`state.rules`). Así no se pierden los cambios de los textareas, incluyendo `__comment`.
- `ruleRow()`: `textarea` de `Comentario` y `Pattern` pasan de `rows=1` a `rows=2` y se evita el auto-resize en el render inicial (solo se aplica al tipear).
- Impacto:
- Los comentarios ya no se descartan al guardar incluso si el panel de JSON contiene modificaciones parciales o inválidas.
- La lista de reglas es más compacta pero legible, con expansión progresiva al editar.

View File

@ -0,0 +1,160 @@
# 🦙 Ollama Model Manager
Script web para gestionar modelos de Ollama de forma sencilla e intuitiva.
## 🚀 Características
- **📋 Listar modelos**: Ve todos los modelos instalados con información detallada
- **📥 Descargar modelos**: Descarga nuevos modelos desde la interfaz web
- **🗑️ Eliminar modelos**: Elimina modelos que ya no necesites
- ** Información detallada**: Ve información completa de cada modelo
- **💾 Monitoreo de espacio**: Controla cuánto espacio ocupan tus modelos
- **🔄 Estado en tiempo real**: Verifica el estado de conexión con Ollama
- **🔴 Cierre elegante**: Botón para cerrar la aplicación y la página web
## 📋 Requisitos
1. **Ollama instalado y ejecutándose**
```bash
# Instalar Ollama (si no está instalado)
curl -fsSL https://ollama.ai/install.sh | sh
# Iniciar Ollama
ollama serve
```
2. **Python 3.7+** con las siguientes librerías:
```bash
pip install flask requests
```
## 🏃‍♂️ Uso Rápido
1. **Ejecutar el script**:
```bash
python manager.py
```
2. **Abrir navegador**: El script abrirá automáticamente tu navegador en `http://127.0.0.1:PUERTO`
3. **¡Listo!** Ya puedes gestionar tus modelos de Ollama
## ⚙️ Configuración
El script puede usar un archivo `script_config.json` opcional para personalizar su comportamiento:
```json
{
"ollama_host": "http://localhost:11434",
"auto_open_browser": true,
"level1": {
"default_models": ["llama3.2", "mistral", "codellama"],
"recommended_models": {
"llama3.2": "Modelo general de Meta",
"mistral": "Modelo rápido y eficiente",
"codellama": "Especializado en programación"
}
}
}
```
### Parámetros de configuración:
- `ollama_host`: URL del servidor Ollama (por defecto: `http://localhost:11434`)
- `auto_open_browser`: Abrir navegador automáticamente (por defecto: `true`)
- `recommended_models`: Lista de modelos recomendados con descripciones
## 🔧 Integración con el Sistema Principal
Este script está diseñado para integrarse con el sistema ParamManagerScripts:
1. **Colocar en directorio de proyectos**: El script se puede colocar en cualquier proyecto del launcher
2. **Ejecutar como script web**: Usar el endpoint `execute-python-web-script` del frontend principal
3. **Configuración automática**: Si está en el sistema principal, carga configuración automáticamente
## 📖 API Endpoints
El script expone los siguientes endpoints:
- `GET /` - Interfaz web principal
- `GET /api/status` - Estado de conexión con Ollama
- `GET /api/models` - Lista de modelos instalados
- `GET /api/models/<name>/info` - Información detallada de un modelo
- `POST /api/models/pull` - Descargar un modelo
- `DELETE /api/models/<name>` - Eliminar un modelo
- `POST /_shutdown` - Cerrar la aplicación (uso interno)
## 🎨 Interfaz Web
La interfaz incluye:
- **Panel de estado**: Muestra si Ollama está conectado y estadísticas generales
- **Botón de cierre**: Permite cerrar la aplicación de forma elegante
- **Barra de acciones**: Campo para descargar nuevos modelos y botón de actualización
- **Grid de modelos**: Tarjetas con información de cada modelo instalado
- **Modal de información**: Detalles completos del modelo (licencia, parámetros, template)
- **Mensajes**: Notificaciones de éxito y error
## 🔍 Modelos Recomendados
### Para uso general:
- **llama3.2** - Modelo versátil de Meta, excelente para conversaciones
- **mistral** - Rápido y eficiente, bueno para tareas generales
- **gemma2** - De Google, bueno para análisis y razonamiento
### Para programación:
- **codellama** - Especializado en código y programación
- **deepseek-coder** - Excelente para tareas de desarrollo
### Para recursos limitados:
- **phi3** - Modelo compacto de Microsoft
- **tinyllama** - Muy pequeño, ideal para pruebas
### Multilingüe:
- **qwen2.5** - Excelente soporte para español y otros idiomas
## 🐛 Solución de Problemas
### Ollama no se conecta
```bash
# Verificar que Ollama esté ejecutándose
ollama serve
# Verificar modelos instalados
ollama list
```
### Error de puerto ocupado
El script encuentra automáticamente un puerto libre. Si hay problemas, reinicia el script.
### Descarga lenta de modelos
Las descargas pueden tardar varios minutos dependiendo del tamaño del modelo y la velocidad de internet. Los modelos grandes (>7B parámetros) pueden ocupar varios GB.
## 📝 Logs y Debug
Para activar logs detallados, modifica `script_config.json`:
```json
{
"level3": {
"debug_mode": true,
"log_api_calls": true
}
}
```
## 🤝 Contribuir
Este script es parte del sistema ParamManagerScripts. Para mejoras o reportar problemas:
1. Crea un issue describiendo el problema o mejora
2. Si es un bug, incluye logs y pasos para reproducir
3. Para nuevas características, explica el caso de uso
## 📄 Licencia
Este script es parte del proyecto ParamManagerScripts y sigue la misma licencia del proyecto principal.
---
**¡Disfruta gestionando tus modelos de Ollama! 🦙✨**

View File

@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
Ollama Model Manager - Script Web para gestionar modelos de Ollama
Permite listar, descargar y eliminar modelos de Ollama con interfaz web.
"""
import os
import sys
import socket
import threading
import webbrowser
import time
import requests
from datetime import datetime
from typing import Dict, Any
from flask import Flask, render_template, jsonify, request
# Configuración del path para importar utilidades del proyecto principal
script_root = os.path.dirname(os.path.dirname(__file__))
sys.path.append(script_root)
# Importar utilidades del proyecto (opcional, para configuración)
try:
from ParamManagerScripts.backend.script_utils import load_configuration
HAS_CONFIG = True
except ImportError:
HAS_CONFIG = False
msg = (
"⚠️ No se pudo importar load_configuration, " "usando configuración por defecto"
)
print(msg)
class OllamaManager:
"""Clase para gestionar modelos de Ollama"""
def __init__(self, base_url: str = "http://localhost:11434"):
self.base_url = base_url.rstrip("/")
self.api_url = f"{self.base_url}/api"
def is_ollama_running(self) -> bool:
"""Verificar si Ollama está ejecutándose"""
try:
response = requests.get(f"{self.base_url}/api/tags", timeout=5)
return response.status_code == 200
except requests.exceptions.RequestException:
return False
def list_models(self) -> Dict[str, Any]:
"""Listar todos los modelos instalados"""
try:
response = requests.get(f"{self.api_url}/tags", timeout=10)
if response.status_code == 200:
data = response.json()
models = data.get("models", [])
# Calcular tamaño total
total_size = sum(model.get("size", 0) for model in models)
return {
"status": "success",
"models": models,
"total_models": len(models),
"total_size": total_size,
"total_size_human": self._format_bytes(total_size),
}
else:
error_msg = f"Error HTTP: {response.status_code}"
return {"status": "error", "message": error_msg}
except requests.exceptions.RequestException as e:
error_msg = f"Error de conexión: {str(e)}"
return {"status": "error", "message": error_msg}
def pull_model(self, model_name: str) -> Dict[str, Any]:
"""Descargar un modelo"""
try:
# Iniciar descarga
response = requests.post(
f"{self.api_url}/pull",
json={"name": model_name},
stream=True,
timeout=300, # 5 minutos timeout
)
if response.status_code == 200:
msg = f"Descarga de '{model_name}' iniciada"
return {"status": "success", "message": msg}
else:
error_msg = f"Error al descargar: {response.status_code}"
return {"status": "error", "message": error_msg}
except requests.exceptions.RequestException as e:
error_msg = f"Error de conexión: {str(e)}"
return {"status": "error", "message": error_msg}
def delete_model(self, model_name: str) -> Dict[str, Any]:
"""Eliminar un modelo"""
try:
response = requests.delete(
f"{self.api_url}/delete", json={"name": model_name}, timeout=30
)
if response.status_code == 200:
msg = f"Modelo '{model_name}' eliminado correctamente"
return {"status": "success", "message": msg}
else:
error_msg = f"Error al eliminar: {response.status_code}"
return {"status": "error", "message": error_msg}
except requests.exceptions.RequestException as e:
error_msg = f"Error de conexión: {str(e)}"
return {"status": "error", "message": error_msg}
def get_model_info(self, model_name: str) -> Dict[str, Any]:
"""Obtener información detallada de un modelo"""
try:
response = requests.post(
f"{self.api_url}/show", json={"name": model_name}, timeout=10
)
if response.status_code == 200:
return {"status": "success", "info": response.json()}
else:
error_msg = f"Error al obtener info: {response.status_code}"
return {"status": "error", "message": error_msg}
except requests.exceptions.RequestException as e:
error_msg = f"Error de conexión: {str(e)}"
return {"status": "error", "message": error_msg}
def _format_bytes(self, bytes_size: int) -> str:
"""Formatear bytes a formato legible"""
if bytes_size == 0:
return "0 B"
for unit in ["B", "KB", "MB", "GB", "TB"]:
if bytes_size < 1024.0:
return f"{bytes_size:.1f} {unit}"
bytes_size /= 1024.0
return f"{bytes_size:.1f} PB"
def find_free_port() -> int:
"""Encontrar un puerto libre"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
return s.getsockname()[1]
def create_app() -> Flask:
"""Crear la aplicación Flask"""
app = Flask(__name__)
# Configurar directorio de templates
template_dir = os.path.join(os.path.dirname(__file__), "templates")
if not os.path.exists(template_dir):
os.makedirs(template_dir)
app.template_folder = template_dir
# Configurar directorio de archivos estáticos
static_dir = os.path.join(os.path.dirname(__file__), "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir)
app.static_folder = static_dir
# Inicializar gestor de Ollama
ollama_manager = OllamaManager()
@app.route("/")
def index():
"""Página principal"""
return render_template("index.html")
@app.route("/api/status")
def api_status():
"""Verificar estado de Ollama"""
is_running = ollama_manager.is_ollama_running()
return jsonify(
{
"status": "success",
"ollama_running": is_running,
"timestamp": datetime.now().isoformat(),
}
)
@app.route("/api/models")
def api_models():
"""Listar modelos"""
return jsonify(ollama_manager.list_models())
@app.route("/api/models/<model_name>/info")
def api_model_info(model_name):
"""Obtener información de un modelo"""
return jsonify(ollama_manager.get_model_info(model_name))
@app.route("/api/models/pull", methods=["POST"])
def api_pull_model():
"""Descargar un modelo"""
data = request.get_json()
if not data or "name" not in data:
error_msg = "Nombre del modelo requerido"
return jsonify({"status": "error", "message": error_msg}), 400
model_name = data["name"].strip()
if not model_name:
error_msg = "Nombre del modelo no puede estar vacío"
return jsonify({"status": "error", "message": error_msg}), 400
result = ollama_manager.pull_model(model_name)
return jsonify(result)
@app.route("/api/models/<model_name>", methods=["DELETE"])
def api_delete_model(model_name):
"""Eliminar un modelo"""
result = ollama_manager.delete_model(model_name)
return jsonify(result)
@app.errorhandler(404)
def not_found(error):
error_msg = "Endpoint no encontrado"
return jsonify({"status": "error", "message": error_msg}), 404
@app.errorhandler(500)
def internal_error(error):
error_msg = "Error interno del servidor"
return jsonify({"status": "error", "message": error_msg}), 500
@app.route("/_shutdown", methods=["POST"])
def shutdown_route():
"""Endpoint interno para cerrar la aplicación"""
print("🛑 Solicitud de cierre recibida desde la interfaz web")
def shutdown_server():
time.sleep(0.1) # Pequeña pausa para permitir respuesta HTTP
try:
# Intentar cerrar el servidor Flask de manera elegante
import os
import signal
os.kill(os.getpid(), signal.SIGINT)
except Exception as e:
print(f"Error al cerrar servidor: {e}")
# Fallback: forzar salida
os._exit(0)
# Ejecutar cierre en hilo separado
shutdown_thread = threading.Thread(target=shutdown_server, daemon=True)
shutdown_thread.start()
return (
jsonify(
{"status": "success", "message": "Cerrando Ollama Model Manager..."}
),
200,
)
return app
def main():
"""Función principal"""
print("🦙 Ollama Model Manager - Iniciando...")
# Cargar configuración si está disponible
configs = {}
if HAS_CONFIG:
try:
configs = load_configuration()
print("✅ Configuración cargada correctamente")
except Exception as e:
print(f"⚠️ Error al cargar configuración: {e}")
# Obtener configuración
ollama_host = configs.get("ollama_host", "http://localhost:11434")
auto_open_browser = configs.get("auto_open_browser", True)
# Crear aplicación Flask
app = create_app()
# Encontrar puerto libre
port = find_free_port()
# URL de la aplicación
app_url = f"http://127.0.0.1:{port}"
print(f"🌐 Servidor iniciado en: {app_url}")
print(f"🦙 Ollama Host: {ollama_host}")
print("📋 Funcionalidades disponibles:")
print(" - Listar modelos instalados")
print(" - Ver información detallada de modelos")
print(" - Descargar nuevos modelos")
print(" - Eliminar modelos existentes")
print(" - Monitoreo de espacio ocupado")
print()
print("⏹️ Presiona Ctrl+C para cerrar el servidor")
# Abrir navegador automáticamente
if auto_open_browser:
def open_browser():
time.sleep(1.0) # Esperar a que Flask inicie
try:
webbrowser.open(app_url)
print(f"🌐 Navegador abierto en: {app_url}")
except Exception as e:
print(f"⚠️ No se pudo abrir el navegador automáticamente: {e}")
print(f"🌐 Abrir manualmente: {app_url}")
timer = threading.Timer(1.0, open_browser)
timer.start()
try:
# Iniciar servidor Flask
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)
except KeyboardInterrupt:
print("\n🛑 Cerrando Ollama Model Manager...")
except Exception as e:
print(f"❌ Error al iniciar el servidor: {e}")
finally:
print("👋 Ollama Model Manager cerrado")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,966 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🦙 Ollama Model Manager</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: rgba(255, 255, 255, 0.95);
border-radius: 15px;
padding: 25px;
margin-bottom: 25px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.header h1 {
color: #4a5568;
font-size: 2.5em;
margin-bottom: 10px;
text-align: center;
}
.header p {
color: #718096;
text-align: center;
font-size: 1.1em;
}
.status-bar {
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 20px;
margin-bottom: 25px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.online {
background: #48bb78;
}
.status-dot.offline {
background: #f56565;
animation: none;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.stats {
display: flex;
gap: 20px;
color: #4a5568;
font-size: 0.9em;
}
.shutdown-section {
margin-left: auto;
}
.main-content {
background: rgba(255, 255, 255, 0.95);
border-radius: 15px;
padding: 25px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.actions-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
flex-wrap: wrap;
gap: 15px;
}
.download-section {
display: flex;
gap: 10px;
align-items: center;
}
.input-group {
position: relative;
}
select {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1em;
background: white;
transition: border-color 0.3s ease;
}
select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input[type="text"] {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1em;
width: 300px;
transition: border-color 0.3s ease;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.btn {
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-danger {
background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%);
color: white;
}
.btn-info {
background: linear-gradient(135deg, #4299e1 0%, #3182ce 100%);
color: white;
}
.btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.models-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.model-card {
background: #f7fafc;
border-radius: 12px;
padding: 20px;
border: 2px solid #e2e8f0;
transition: all 0.3s ease;
}
.model-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
border-color: #667eea;
}
.model-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.model-name {
font-size: 1.2em;
font-weight: 700;
color: #2d3748;
word-break: break-word;
}
.model-tag {
background: #667eea;
color: white;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.8em;
font-weight: 600;
}
.model-info {
margin-bottom: 15px;
color: #4a5568;
}
.model-info div {
margin-bottom: 5px;
font-size: 0.9em;
}
.model-size {
font-weight: 600;
color: #2d3748;
}
.model-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.loading {
text-align: center;
padding: 40px;
color: #718096;
}
.spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid #e2e8f0;
border-radius: 50%;
border-top-color: #667eea;
animation: spin 1s ease-in-out infinite;
margin-bottom: 15px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error {
background: #fed7d7;
color: #c53030;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #f56565;
}
.success {
background: #c6f6d5;
color: #2f855a;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border-left: 4px solid #48bb78;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #718096;
}
.empty-state h3 {
font-size: 1.5em;
margin-bottom: 15px;
color: #4a5568;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 30px;
border-radius: 15px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e2e8f0;
}
.modal-header h2 {
color: #2d3748;
margin: 0;
}
.close {
color: #a0aec0;
font-size: 28px;
font-weight: bold;
cursor: pointer;
transition: color 0.3s ease;
}
.close:hover {
color: #4a5568;
}
.info-item {
margin-bottom: 15px;
padding: 10px;
background: #f7fafc;
border-radius: 8px;
}
.info-label {
font-weight: 600;
color: #4a5568;
margin-bottom: 5px;
}
.info-value {
color: #2d3748;
word-break: break-word;
}
@media (max-width: 768px) {
.container {
padding: 15px;
}
.models-grid {
grid-template-columns: 1fr;
}
input[type="text"] {
width: 100%;
}
.actions-bar {
flex-direction: column;
align-items: stretch;
}
.download-section {
flex-direction: column;
}
.status-bar {
flex-direction: column;
gap: 15px;
text-align: center;
}
.shutdown-section {
margin-left: 0;
align-self: center;
}
.stats {
justify-content: center;
text-align: center;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1>🦙 Ollama Model Manager</h1>
<p>Gestiona tus modelos de Ollama de forma sencilla</p>
</div>
<!-- Status Bar -->
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">Verificando conexión...</span>
</div>
<div class="stats" id="stats">
<span>📊 Cargando estadísticas...</span>
</div>
<div class="shutdown-section">
<button class="btn btn-danger" id="shutdown-btn" onclick="shutdownApplication()"
title="Cerrar aplicación">
🔴 Cerrar
</button>
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Actions Bar -->
<div class="actions-bar">
<h2>Modelos Instalados</h2>
<div class="download-section">
<div class="input-group">
<input type="text" id="model-name-input" placeholder="Nombre del modelo (ej: llama3.2, mistral)"
autocomplete="off">
</div>
<button class="btn btn-primary" id="download-btn" onclick="downloadModel()">
📥 Descargar
</button>
<button class="btn btn-secondary" onclick="refreshModels()">
🔄 Actualizar
</button>
<div class="input-group">
<label for="sort-select"
style="margin-left:10px; margin-right:6px; color:#4a5568; font-weight:600;">Ordenar
por:</label>
<select id="sort-select">
<option value="modified_desc">Última modificación (recientes primero)</option>
<option value="modified_asc">Última modificación (antiguos primero)</option>
<option value="size_desc">Tamaño (grandes primero)</option>
<option value="size_asc">Tamaño (pequeños primero)</option>
<option value="name_asc">Nombre (A-Z)</option>
</select>
</div>
</div>
</div>
<!-- Messages -->
<div id="messages"></div>
<!-- Models Grid -->
<div id="models-container">
<div class="loading">
<div class="spinner"></div>
<div>Cargando modelos...</div>
</div>
</div>
</div>
</div>
<!-- Modal para información del modelo -->
<div id="info-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>📋 Información del Modelo</h2>
<span class="close" onclick="closeInfoModal()">&times;</span>
</div>
<div id="info-modal-body">
<div class="loading">
<div class="spinner"></div>
<div>Cargando información...</div>
</div>
</div>
</div>
</div>
<script>
// Estado global de la aplicación
let currentModels = [];
let isLoading = false;
let currentSort = 'modified_desc';
// Inicializar aplicación
document.addEventListener('DOMContentLoaded', function () {
checkOllamaStatus();
loadModels();
// Configurar entrada de texto para descargar modelos
document.getElementById('model-name-input').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
downloadModel();
}
});
// Configurar selector de orden
const sortSelect = document.getElementById('sort-select');
if (sortSelect) {
sortSelect.value = currentSort;
sortSelect.addEventListener('change', function () {
currentSort = this.value;
applySortAndRender();
});
}
});
// Verificar estado de Ollama
async function checkOllamaStatus() {
try {
const response = await fetch('/api/status');
const data = await response.json();
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
if (data.ollama_running) {
statusDot.className = 'status-dot online';
statusText.textContent = '🟢 Ollama conectado';
} else {
statusDot.className = 'status-dot offline';
statusText.textContent = '🔴 Ollama desconectado';
}
} catch (error) {
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
statusDot.className = 'status-dot offline';
statusText.textContent = '❌ Error de conexión';
}
}
// Cargar modelos
async function loadModels() {
if (isLoading) return;
isLoading = true;
const container = document.getElementById('models-container');
// Mostrar spinner de carga
container.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<div>Cargando modelos...</div>
</div>
`;
try {
const response = await fetch('/api/models');
const data = await response.json();
if (data.status === 'success') {
currentModels = data.models || [];
updateStats(data);
applySortAndRender();
} else {
showError(`Error al cargar modelos: ${data.message}`);
container.innerHTML = `
<div class="error">
❌ Error al cargar modelos: ${data.message}
</div>
`;
}
} catch (error) {
showError(`Error de conexión: ${error.message}`);
container.innerHTML = `
<div class="error">
❌ Error de conexión: ${error.message}
</div>
`;
} finally {
isLoading = false;
}
}
// Aplicar orden y renderizar
function applySortAndRender() {
const sorted = [...currentModels];
const getSize = (m) => typeof m.size === 'number' ? m.size : (parseInt(m.size, 10) || 0);
const getModifiedTs = (m) => m.modified_at ? (Date.parse(m.modified_at) || 0) : 0;
switch (currentSort) {
case 'size_desc':
sorted.sort((a, b) => getSize(b) - getSize(a));
break;
case 'size_asc':
sorted.sort((a, b) => getSize(a) - getSize(b));
break;
case 'modified_asc':
sorted.sort((a, b) => getModifiedTs(a) - getModifiedTs(b));
break;
case 'name_asc':
sorted.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
break;
case 'modified_desc':
default:
sorted.sort((a, b) => getModifiedTs(b) - getModifiedTs(a));
break;
}
renderModels(sorted);
}
// Actualizar estadísticas
function updateStats(data) {
const statsElement = document.getElementById('stats');
statsElement.innerHTML = `
<span>📦 ${data.total_models} modelos</span>
<span>💾 ${data.total_size_human}</span>
`;
}
// Renderizar modelos
function renderModels(models) {
const container = document.getElementById('models-container');
if (models.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>🦙 No hay modelos instalados</h3>
<p>Descarga tu primer modelo usando el campo de arriba</p>
<p><strong>Modelos populares:</strong> llama3.2, mistral, codellama, phi3</p>
</div>
`;
return;
}
const modelsHTML = models.map(model => {
const sizeFormatted = formatBytes(model.size || 0);
const modifiedDate = model.modified_at ?
new Date(model.modified_at).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) : 'N/A';
return `
<div class="model-card">
<div class="model-header">
<div class="model-name">${model.name}</div>
<div class="model-tag">LLM</div>
</div>
<div class="model-info">
<div class="model-size">📦 Tamaño: ${sizeFormatted}</div>
<div>📅 Modificado: ${modifiedDate}</div>
<div>🏷️ Digest: ${(model.digest || 'N/A').substring(0, 12)}...</div>
</div>
<div class="model-actions">
<button class="btn btn-info" onclick="showModelInfo('${model.name}')">
Info
</button>
<button class="btn btn-danger" onclick="deleteModel('${model.name}')">
🗑️ Eliminar
</button>
</div>
</div>
`;
}).join('');
container.innerHTML = `<div class="models-grid">${modelsHTML}</div>`;
}
// Descargar modelo
async function downloadModel() {
const input = document.getElementById('model-name-input');
const modelName = input.value.trim();
if (!modelName) {
showError('Por favor, introduce el nombre del modelo');
return;
}
const downloadBtn = document.getElementById('download-btn');
downloadBtn.disabled = true;
downloadBtn.innerHTML = '⏳ Descargando...';
try {
const response = await fetch('/api/models/pull', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: modelName })
});
const data = await response.json();
if (data.status === 'success') {
showSuccess(`✅ Descarga de "${modelName}" iniciada. Puede tardar varios minutos...`);
input.value = '';
// Recargar modelos después de unos segundos
setTimeout(() => {
loadModels();
}, 3000);
} else {
showError(`❌ Error al descargar: ${data.message}`);
}
} catch (error) {
showError(`❌ Error de conexión: ${error.message}`);
} finally {
downloadBtn.disabled = false;
downloadBtn.innerHTML = '📥 Descargar';
}
}
// Eliminar modelo
async function deleteModel(modelName) {
if (!confirm(`¿Estás seguro de que quieres eliminar el modelo "${modelName}"?`)) {
return;
}
try {
const response = await fetch(`/api/models/${encodeURIComponent(modelName)}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.status === 'success') {
showSuccess(`✅ Modelo "${modelName}" eliminado correctamente`);
loadModels();
} else {
showError(`❌ Error al eliminar: ${data.message}`);
}
} catch (error) {
showError(`❌ Error de conexión: ${error.message}`);
}
}
// Mostrar información del modelo
async function showModelInfo(modelName) {
const modal = document.getElementById('info-modal');
const modalBody = document.getElementById('info-modal-body');
modal.style.display = 'block';
modalBody.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<div>Cargando información...</div>
</div>
`;
try {
const response = await fetch(`/api/models/${encodeURIComponent(modelName)}/info`);
const data = await response.json();
if (data.status === 'success') {
const info = data.info;
modalBody.innerHTML = `
<div class="info-item">
<div class="info-label">Nombre del Modelo</div>
<div class="info-value">${modelName}</div>
</div>
${info.license ? `
<div class="info-item">
<div class="info-label">Licencia</div>
<div class="info-value">${info.license}</div>
</div>
` : ''}
${info.modelfile ? `
<div class="info-item">
<div class="info-label">Modelfile</div>
<div class="info-value"><pre style="white-space: pre-wrap; font-size: 0.9em;">${info.modelfile}</pre></div>
</div>
` : ''}
${info.parameters ? `
<div class="info-item">
<div class="info-label">Parámetros</div>
<div class="info-value"><pre style="white-space: pre-wrap; font-size: 0.9em;">${info.parameters}</pre></div>
</div>
` : ''}
${info.template ? `
<div class="info-item">
<div class="info-label">Template</div>
<div class="info-value"><pre style="white-space: pre-wrap; font-size: 0.9em;">${info.template}</pre></div>
</div>
` : ''}
`;
} else {
modalBody.innerHTML = `
<div class="error">
❌ Error al cargar información: ${data.message}
</div>
`;
}
} catch (error) {
modalBody.innerHTML = `
<div class="error">
❌ Error de conexión: ${error.message}
</div>
`;
}
}
// Cerrar modal de información
function closeInfoModal() {
document.getElementById('info-modal').style.display = 'none';
}
// Cerrar modal al hacer clic fuera de él
window.onclick = function (event) {
const modal = document.getElementById('info-modal');
if (event.target === modal) {
modal.style.display = 'none';
}
}
// Actualizar modelos
function refreshModels() {
checkOllamaStatus();
loadModels();
}
// Mostrar mensaje de error
function showError(message) {
showMessage(message, 'error');
}
// Mostrar mensaje de éxito
function showSuccess(message) {
showMessage(message, 'success');
}
// Mostrar mensaje genérico
function showMessage(message, type) {
const messagesContainer = document.getElementById('messages');
const messageDiv = document.createElement('div');
messageDiv.className = type;
messageDiv.textContent = message;
messagesContainer.appendChild(messageDiv);
// Remover mensaje después de 5 segundos
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.parentNode.removeChild(messageDiv);
}
}, 5000);
}
// Formatear bytes
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Cerrar aplicación
async function shutdownApplication() {
if (!confirm('¿Estás seguro de que quieres cerrar la aplicación?')) {
return;
}
const shutdownBtn = document.getElementById('shutdown-btn');
shutdownBtn.disabled = true;
shutdownBtn.innerHTML = '⏳ Cerrando...';
try {
// Mostrar mensaje de cierre
showSuccess('🛑 Cerrando Ollama Model Manager...');
// Esperar un momento para que se vea el mensaje
await new Promise(resolve => setTimeout(resolve, 1000));
// Enviar solicitud de cierre al servidor
const response = await fetch('/_shutdown', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
// Intentar cerrar la pestaña/ventana del navegador
try {
window.close();
} catch (e) {
// Si no se puede cerrar, mostrar mensaje
document.body.innerHTML = `
<div style="
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-family: 'Segoe UI', sans-serif;
text-align: center;
padding: 20px;
">
<h1 style="font-size: 3em; margin-bottom: 20px;">🦙</h1>
<h2 style="margin-bottom: 20px;">Ollama Model Manager Cerrado</h2>
<p style="font-size: 1.2em; margin-bottom: 30px;">
La aplicación se ha cerrado correctamente.
</p>
<p style="opacity: 0.8;">
Puedes cerrar esta pestaña manualmente.
</p>
</div>
`;
}
} else {
throw new Error('Error en la respuesta del servidor');
}
} catch (error) {
console.error('Error cerrando aplicación:', error);
showError('❌ Error al cerrar la aplicación');
// Restaurar botón
shutdownBtn.disabled = false;
shutdownBtn.innerHTML = '🔴 Cerrar';
}
}
</script>
</body>
</html>

View File

@ -1,227 +1,259 @@
{
"__documentation": {
"__format": "Las reglas siguen el siguiente formato:",
"pattern": "Patrón a buscar - puede ser texto o regex",
"replacement": "Texto que reemplazará al patrón (puede estar vacío)",
"action": "Tipo de acción: replace, remove_line, remove_block, add_before, add_after",
"type": "Cómo interpretar el patrón: string, regex, left, right, substring",
"priority": "Orden de ejecución (menor número = mayor prioridad)"
"pattern": "Patrón a buscar - puede ser texto o regex",
"priority": "Orden de ejecución (menor número = mayor prioridad)",
"replacement": "Texto que reemplazará al patrón (puede estar vacío)",
"type": "Cómo interpretar el patrón: string, regex, left, right, substring"
},
"__examples": {
"replace": "Reemplaza texto: reemplaza cada coincidencia por el replacement",
"remove_line": "Elimina línea: elimina la línea completa si encuentra el patrón",
"remove_block": "Elimina bloque: elimina desde el inicio hasta el fin del patrón con .....",
"add_after": "Agrega después: inserta el replacement después de la línea con el patrón",
"add_before": "Agrega antes: inserta el replacement antes de la línea con el patrón",
"add_after": "Agrega después: inserta el replacement después de la línea con el patrón"
"remove_block": "Elimina bloque: elimina desde el inicio hasta el fin del patrón con .....",
"remove_line": "Elimina línea: elimina la línea completa si encuentra el patrón",
"replace": "Reemplaza texto: reemplaza cada coincidencia por el replacement"
},
"rules": [
{
"__comment": "Reemplaza non-breaking space por espacio normal",
"pattern": "\u00a0",
"replacement": " ",
"action": "replace",
"type": "string",
"priority": 1
"pattern": " ",
"priority": 1,
"replacement": " ",
"type": "string"
},
{
"__comment": "Elimina marcador de mensaje original",
"pattern": "--- Messaggio originale ---",
"replacement": "***",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "--- Messaggio originale ---",
"priority": 2,
"replacement": "***",
"type": "substring"
},
{
"__comment": "Elimina firma de dispositivo móvil",
"pattern": "(?m)^Sent from my.*$",
"replacement": "",
"action": "remove_line",
"type": "regex",
"priority": 2
"pattern": "(?m)^Sent from my.*$",
"priority": 2,
"replacement": "",
"type": "regex"
},
{
"__comment": "Elimina aviso medioambiental",
"pattern": "(?m)^Please take care of the environment.*$",
"replacement": "",
"action": "remove_line",
"type": "regex",
"priority": 2
"pattern": "(?m)^Please take care of the environment.*$",
"priority": 2,
"replacement": "",
"type": "regex"
},
{
"__comment": "Elimina aviso de mensaje automático",
"pattern": "(?m)^This message is from an.*$",
"replacement": "",
"action": "remove_line",
"type": "regex",
"priority": 2
"pattern": "(?m)^This message is from an.*$",
"priority": 2,
"replacement": "",
"type": "regex"
},
{
"__comment": "Elimina aviso de confidencialidad en italiano",
"pattern": "eventuali allegati sono confidenziali",
"replacement": "",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "eventuali allegati sono confidenziali",
"priority": 2,
"replacement": "",
"type": "substring"
},
{
"__comment": "Elimina aviso de confidencialidad en inglés",
"pattern": "any attachments are confidential",
"replacement": "",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "any attachments are confidential",
"priority": 2,
"replacement": "",
"type": "substring"
},
{
"__comment": "Elimina solicitud de LinkedIn",
"pattern": "Please sign up on our Linkedin",
"replacement": "",
"action": "remove_line",
"type": "left",
"priority": 2
"pattern": "Please sign up on our Linkedin",
"priority": 2,
"replacement": "",
"type": "left"
},
{
"__comment": "Elimina aviso de no compartir contenido",
"pattern": "di non copiare o condividere i contenuti con nessuno",
"replacement": "",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "di non copiare o condividere i contenuti con nessuno",
"priority": 2,
"replacement": "",
"type": "substring"
},
{
"__comment": "Elimina líneas de email individual",
"pattern": "(?m)^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"replacement": "",
"action": "remove_line",
"type": "regex",
"priority": 2
"pattern": "(?m)^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"priority": 2,
"replacement": "",
"type": "regex"
},
{
"__comment": "Elimina líneas con múltiples emails",
"pattern": "(?m)(?:^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\\s*(?:;\\s*)?$|^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\\s*;\\s*[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}.*$)",
"replacement": "",
"action": "remove_line",
"type": "regex",
"priority": 2
"pattern": "(?m)(?:^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\\s*(?:;\\s*)?$|^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}\\s*;\\s*[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}.*$)",
"priority": 2,
"replacement": "",
"type": "regex"
},
{
"__comment": "Elimina línea de teléfono",
"pattern": "Phone:",
"replacement": "",
"action": "remove_line",
"type": "left",
"priority": 2
"pattern": "Phone:",
"priority": 2,
"replacement": "",
"type": "left"
},
{
"__comment": "Elimina línea de móvil",
"pattern": "Mobile:",
"replacement": "",
"action": "remove_line",
"type": "left",
"priority": 2
"pattern": "Mobile:",
"priority": 2,
"replacement": "",
"type": "left"
},
{
"__comment": "Elimina línea de CC",
"pattern": "Cc:",
"replacement": "",
"action": "remove_line",
"type": "left",
"priority": 2
"pattern": "Cc:",
"priority": 2,
"replacement": "",
"type": "left"
},
{
"__comment": "Elimina línea de destinatario (italiano)",
"pattern": "A:",
"replacement": "",
"action": "remove_line",
"type": "left",
"priority": 2
"pattern": "A:",
"priority": 2,
"replacement": "",
"type": "left"
},
{
"__comment": "Elimina línea de destinatario",
"pattern": "To:",
"replacement": "",
"action": "remove_line",
"type": "left",
"priority": 2
"pattern": "To:",
"priority": 2,
"replacement": "",
"type": "left"
},
{
"__comment": "Agrega separador antes del asunto",
"pattern": "Subject: ",
"replacement": "***",
"action": "add_before",
"type": "left",
"priority": 3
"pattern": "Subject: ",
"priority": 3,
"replacement": "***",
"type": "left"
},
{
"__comment": "Elimina firma corporativa",
"pattern": "Strada Isolanda.....Website:www.vetromeccanica.it",
"replacement": "",
"action": "remove_block",
"type": "string",
"priority": 4
"pattern": "Strada Isolanda.....Website:www.vetromeccanica.it",
"priority": 4,
"replacement": "",
"type": "string"
},
{
"__comment": "Elimina aviso legal largo",
"pattern": "IMPORTANT NOTICE: This message may.....without retaining any copy",
"replacement": "",
"action": "remove_block",
"type": "string",
"priority": 4
"pattern": "IMPORTANT NOTICE: This message may.....without retaining any copy",
"priority": 4,
"replacement": "",
"type": "string"
},
{
"__comment": "Elimina aviso confidencialidad (inglés ALPLA)",
"pattern": "This message is confidential and intended solely",
"replacement": "",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "This message is confidential and intended solely",
"priority": 2,
"replacement": "",
"type": "substring"
},
{
"__comment": "Elimina aviso de responsabilidad ALPLA (inglés)",
"pattern": "To the extent permitted by law ALPLA",
"replacement": "",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "To the extent permitted by law ALPLA",
"priority": 2,
"replacement": "",
"type": "substring"
},
{
"__comment": "Elimina aviso confidencialidad (alemán ALPLA)",
"pattern": "Diese Nachricht ist vertraulich und nur",
"replacement": "",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "Diese Nachricht ist vertraulich und nur",
"priority": 2,
"replacement": "",
"type": "substring"
},
{
"__comment": "Elimina aviso términos y condiciones ALPLA (alemán)",
"pattern": "Wir liefern und bestellen ausschließlich",
"replacement": "",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "Wir liefern und bestellen ausschließlich",
"priority": 2,
"replacement": "",
"type": "substring"
},
{
"__comment": "Elimina enlace GTC ALPLA",
"pattern": "alink.alpla.com/GTC",
"replacement": "",
"action": "remove_line",
"type": "substring",
"priority": 2
"pattern": "alink.alpla.com/GTC",
"priority": 2,
"replacement": "",
"type": "substring"
},
{
"__comment": "Elimina aviso de seguridad EXTERNAL SENDER (caution)",
"pattern": "CAUTION: This message is from an EXTERNAL SENDER ..... know the content is safe!",
"replacement": "",
"action": "remove_block",
"type": "string",
"priority": 4
"pattern": "CAUTION: This message is from an EXTERNAL SENDER ..... know the content is safe!",
"priority": 4,
"replacement": "",
"type": "string"
},
{
"__comment": "Elimina disclaimer confidencialidad (alemán + inglés)",
"pattern": "Diese E-Mail enthält vertrauliche ..... strictly prohibited.",
"replacement": "",
"action": "remove_block",
"type": "string",
"priority": 4
"pattern": "Diese E-Mail enthält vertrauliche ..... strictly prohibited.",
"priority": 4,
"replacement": "",
"type": "string"
},
{
"__comment": "Tel:.....www.sidel.com",
"action": "remove_block",
"pattern": "Tel:.....www.sidel.com",
"priority": 5,
"replacement": "",
"type": "string"
},
{
"__comment": "Sidel Group.....www.sidel.com",
"action": "remove_block",
"pattern": "Sidel Group.....www.sidel.com",
"priority": 5,
"replacement": "",
"type": "string"
},
{
"__comment": "Sidel Group.....www.sidel.com",
"action": "remove_block",
"pattern": "Sidel Group.....43126 Parma, ITALY",
"priority": 5,
"replacement": "",
"type": "string"
},
{
"__comment": "This e-mail may contain confidential ..... strictly forbidden.",
"action": "remove_block",
"pattern": "This e-mail may contain confidential ..... strictly forbidden.",
"priority": 5,
"replacement": "",
"type": "string"
}
]
}

View File

@ -4,5 +4,11 @@
"short_description": "Script para desensamblar los emails y generar un archivo md con la cronología de los mensajes.",
"long_description": "## Descripción de funcionamiento:\n***\nEste script procesa archivos de correo electrónico (`.eml`) para extraer su contenido, gestionar adjuntos y generar un archivo Markdown que presenta los mensajes en orden cronológico inverso.\n***\n**Lógica Principal:**\n\n1. **Configuración:** Carga parámetros desde `ParamManagerScripts` (directorio de trabajo, nombre del archivo de salida Markdown, nombre del directorio de adjuntos).\n2. **Beautify:** Carga reglas de embellecimiento de texto desde `config/beautify_rules.json` para limpiar el contenido de los correos.\n3. **Descubrimiento:** Busca todos los archivos `.eml` en el directorio de trabajo configurado.\n4. **Procesamiento Individual:**\n * Itera sobre cada archivo `.eml` encontrado.\n * Utiliza `utils.email_parser.procesar_eml` para extraer metadatos (fecha, asunto, remitente, destinatarios), contenido del cuerpo y guardar los archivos adjuntos en la carpeta especificada.\n * Calcula un hash para cada mensaje para detectar duplicados.\n * Si un mensaje es nuevo (no duplicado):\n * Aplica las reglas de `BeautifyProcessor` al contenido del cuerpo.\n * Añade el mensaje procesado a una lista.\n5. **Ordenación:** Ordena la lista de mensajes únicos por fecha, del más reciente al más antiguo.\n6. **Generación de Índice:** Crea una sección de índice en formato Markdown con enlaces internos a cada mensaje.\n7. **Salida Markdown:** Escribe el índice seguido del contenido formateado en Markdown de cada mensaje en el archivo de salida configurado (ej. `cronologia.md`).\n",
"hidden": false
},
"x2.py": {
"display_name": "Editor de Reglas de Embellecimiento",
"short_description": "Aplicación web para gestionar las reglas de embellecimiento de texto en beautify_rules.json.",
"long_description": "## Descripción de funcionamiento:\n***\nEste script proporciona una interfaz web para gestionar las reglas de embellecimiento de texto que se utilizan en el procesamiento de emails. Permite crear, editar, reordenar y probar reglas de limpieza de contenido.\n***\n**Lógica Principal:**\n\n1. **Configuración:** Carga el archivo `config/beautify_rules.json` y detecta la ruta a `cronologia.md` desde la configuración del launcher.\n2. **Servidor Web:** Inicia una aplicación Flask en el puerto 5137 con interfaz web moderna.\n3. **Gestión de Reglas:** Proporciona operaciones CRUD completas para las reglas de embellecimiento:\n * **Crear/Editar:** Validación de reglas con campos obligatorios (pattern, replacement, action, type, priority).\n * **Reordenar:** Cambiar la prioridad y orden de aplicación de las reglas.\n * **Eliminar:** Remover reglas específicas del archivo.\n * **Backup:** Crea automáticamente copias de seguridad antes de guardar cambios.\n4. **Validación:** Verifica que las reglas cumplan con el formato esperado:\n * Acciones permitidas: replace, remove_line, remove_block, add_before, add_after.\n * Tipos de patrón: string, regex, left, right, substring.\n * Campos obligatorios y tipos de datos correctos.\n5. **Testing en Tiempo Real:** Permite probar reglas directamente sobre el contenido de `cronologia.md`:\n * Vista previa del resultado antes de aplicar cambios.\n * Soporte para todos los tipos de acciones y patrones.\n * Recorte inteligente de contenido para mostrar solo fragmentos relevantes.\n6. **Auto-cierre:** Sistema de monitor de inactividad que cierra la aplicación automáticamente después de un período sin actividad.\n7. **Integración:** Se abre automáticamente en el navegador y se integra con el flujo de trabajo del procesador de emails.\n",
"hidden": false
}
}

View File

@ -0,0 +1,279 @@
const state = {
data: null,
rules: [],
};
const el = (sel) => document.querySelector(sel);
function setStatus(msg, type = "info") {
const s = el('#status');
s.textContent = msg || '';
s.className = `status ${type}`;
}
async function fetchJSON(url, options = {}) {
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok || data.status === 'error') {
const message = data && data.message ? data.message : `HTTP ${res.status}`;
throw new Error(message);
}
return data;
}
function ruleRow(rule, index) {
const div = document.createElement('div');
div.className = 'rule-row';
div.innerHTML = `
<div><textarea class="comment autoresize" rows="2" placeholder="Comentario">${rule.__comment || ''}</textarea></div>
<div><textarea class="pattern autoresize" rows="2" placeholder="Pattern">${rule.pattern || ''}</textarea></div>
<div><input class="replacement" value="${rule.replacement || ''}" placeholder="Replacement"/></div>
<div>
<select class="action">
${['replace', 'remove_line', 'remove_block', 'add_before', 'add_after'].map(a => `<option value="${a}" ${rule.action === a ? 'selected' : ''}>${a}</option>`).join('')}
</select>
</div>
<div>
<select class="type">
${['string', 'regex', 'left', 'right', 'substring'].map(t => `<option value="${t}" ${rule.type === t ? 'selected' : ''}>${t}</option>`).join('')}
</select>
</div>
<div><input class="priority" type="number" value="${Number.isInteger(rule.priority) ? rule.priority : 5}"/></div>
<div class="actions">
<button class="up"></button>
<button class="down"></button>
<button class="dup"></button>
<button class="del"></button>
<button class="test">Edicion y Test</button>
</div>
`;
// bindings
const cEl = div.querySelector('.comment');
const pEl = div.querySelector('.pattern');
cEl.addEventListener('input', (e) => { state.rules[index].__comment = e.target.value; autoResize(e.target); });
pEl.addEventListener('input', (e) => { state.rules[index].pattern = e.target.value; autoResize(e.target); });
// Nota: no auto-redimensionar en el render inicial para mantener 2 líneas visibles
div.querySelector('.replacement').addEventListener('input', (e) => state.rules[index].replacement = e.target.value);
div.querySelector('.action').addEventListener('change', (e) => state.rules[index].action = e.target.value);
div.querySelector('.type').addEventListener('change', (e) => state.rules[index].type = e.target.value);
div.querySelector('.priority').addEventListener('input', (e) => state.rules[index].priority = parseInt(e.target.value, 10));
div.querySelector('.up').addEventListener('click', () => moveRule(index, -1));
div.querySelector('.down').addEventListener('click', () => moveRule(index, 1));
div.querySelector('.del').addEventListener('click', () => removeRule(index));
div.querySelector('.dup').addEventListener('click', () => duplicateRule(index));
div.querySelector('.test').addEventListener('click', () => openModal(index));
return div;
}
function render() {
const list = el('#rulesList');
list.innerHTML = '';
state.rules
.map((r, i) => [r, i])
.sort((a, b) => (a[0].priority ?? 9999) - (b[0].priority ?? 9999) || a[1] - b[1])
.forEach(([r, i]) => list.appendChild(ruleRow(r, i)));
const raw = {
...state.data,
rules: state.rules,
};
el('#rawJson').value = JSON.stringify(raw, null, 2);
}
function moveRule(index, delta) {
const newIndex = index + delta;
if (newIndex < 0 || newIndex >= state.rules.length) return;
const tmp = state.rules[index];
state.rules[index] = state.rules[newIndex];
state.rules[newIndex] = tmp;
render();
}
function removeRule(index) {
state.rules.splice(index, 1);
render();
}
function duplicateRule(index) {
const original = state.rules[index];
const copy = JSON.parse(JSON.stringify(original));
state.rules.splice(index + 1, 0, copy);
render();
}
function addRule() {
state.rules.push({
__comment: '',
pattern: '',
replacement: '',
action: 'replace',
type: 'string',
priority: 5,
});
render();
}
async function loadAll() {
try {
setStatus('Cargando...', 'info');
const [meta, rulesRes] = await Promise.all([
fetchJSON('/api/meta'),
fetchJSON('/api/rules'),
]);
state.data = rulesRes.data;
state.rules = Array.isArray(rulesRes.data.rules) ? JSON.parse(JSON.stringify(rulesRes.data.rules)) : [];
el('#metaInfo').textContent = meta.exists ? `${meta.path} (${meta.size} bytes) — modificado: ${meta.modified}` : 'Archivo no encontrado';
render();
setStatus('Listo', 'success');
} catch (e) {
setStatus(`Error: ${e.message}`, 'error');
}
}
async function saveAll() {
try {
setStatus('Guardando...', 'info');
// Fusionar: tomar el JSON avanzado si es válido, pero SIEMPRE usar las reglas del estado actual
const payloadFromState = { ...state.data, rules: state.rules };
let payload = payloadFromState;
try {
const parsed = JSON.parse(el('#rawJson').value);
payload = { ...parsed, rules: state.rules };
} catch (_) { /* si el JSON está inválido, persistimos el estado visible */ }
const res = await fetchJSON('/api/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
setStatus(`${res.message} (backup: ${res.backup})`, 'success');
await loadAll();
} catch (e) {
setStatus(`Error: ${e.message}`, 'error');
}
}
function wireUI() {
const bind = (selector, event, handler) => {
const node = el(selector);
if (node) node.addEventListener(event, handler);
return node;
};
bind('#btnReload', 'click', loadAll);
bind('#btnSave', 'click', saveAll);
bind('#btnAdd', 'click', addRule);
bind('#btnShutdown', 'click', async () => {
try {
await fetchJSON('/_shutdown', { method: 'POST' });
} catch (_) { }
try { window.close(); } catch (_) { }
});
// modal
bind('#modalClose', 'click', closeModal);
bind('#btnModalCancel', 'click', closeModal);
bind('#btnModalApply', 'click', applyModalToRule);
bind('#btnModalTest', 'click', testModalRule);
}
document.addEventListener('DOMContentLoaded', async () => {
wireUI();
await loadAll();
// Iniciar heartbeat periódico para indicar presencia del cliente
try {
const beat = async () => {
try {
await fetch('/api/heartbeat', { cache: 'no-store' });
} catch (_) { /* noop */ }
};
// primer latido inmediato y luego cada 10s
beat();
setInterval(beat, 10000);
} catch (_) { /* noop */ }
});
// --- Auto-resize helpers ---
function autoResize(t) {
t.style.height = 'auto';
t.style.height = (t.scrollHeight) + 'px';
}
// --- Modal logic ---
let modalIndex = -1;
function openModal(index) {
modalIndex = index;
const r = state.rules[index];
// fill selects
el('#mAction').innerHTML = ['replace', 'remove_line', 'remove_block', 'add_before', 'add_after']
.map(a => `<option value="${a}" ${r.action === a ? 'selected' : ''}>${a}</option>`).join('');
el('#mType').innerHTML = ['string', 'regex', 'left', 'right', 'substring']
.map(t => `<option value="${t}" ${r.type === t ? 'selected' : ''}>${t}</option>`).join('');
el('#mPattern').value = r.pattern || '';
el('#mReplacement').value = r.replacement || '';
el('#mOriginal').textContent = '';
el('#mProcessed').textContent = '';
updateActionHelp();
el('#mAction').addEventListener('change', updateActionHelp, { once: false });
document.getElementById('modal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('modal').classList.add('hidden');
modalIndex = -1;
}
function applyModalToRule() {
if (modalIndex < 0) return;
const r = state.rules[modalIndex];
r.pattern = el('#mPattern').value;
r.replacement = el('#mReplacement').value;
r.action = el('#mAction').value;
r.type = el('#mType').value;
closeModal();
render();
}
async function testModalRule() {
try {
const payload = {
pattern: el('#mPattern').value,
replacement: el('#mReplacement').value,
action: el('#mAction').value,
type: el('#mType').value,
max_chars: 4000,
};
const res = await fetchJSON('/api/test_pattern', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
el('#mOriginal').textContent = res.original_excerpt || '';
el('#mProcessed').textContent = res.processed_excerpt || '';
el('#modalMeta').textContent = `Longitud original: ${res.total_len}`;
} catch (e) {
el('#modalMeta').textContent = `Error: ${e.message}`;
}
}
function actionHelpText(action) {
const map = {
replace: 'Reemplaza coincidencias de "pattern" por "replacement". Respeta el "type": string/substring (reemplazo literal), regex (expresión regular), left/right (aplica por línea al inicio/fin).',
remove_line: 'Elimina las líneas que coinciden con "pattern" según el "type". Si la línea siguiente queda en blanco, también se elimina.',
remove_block: 'Elimina bloques completos que coincidan con el patrón multi-línea. Usa "....." como comodín para cualquier texto entre medio. Ej: "Inicio.....Fin" borrará todo desde la línea con "Inicio" hasta la línea con "Fin".',
add_before: 'Inserta el contenido de "replacement" en una nueva línea antes de cada línea que coincida con "pattern" (según el "type").',
add_after: 'Inserta el contenido de "replacement" en una nueva línea después de cada línea que coincida con "pattern" (según el "type").',
};
return map[action] || '';
}
function updateActionHelp() {
const sel = el('#mAction');
if (!sel) return;
const txt = actionHelpText(sel.value);
const node = el('#mActionHelp');
if (node) node.textContent = txt;
}

View File

@ -0,0 +1,245 @@
:root {
--bg: #0f172a;
--panel: #111827;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
--border: #1f2937;
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji';
}
.app-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #0b1220, #0f172a);
}
.app-header h1 {
margin: 0 0 6px;
font-size: 18px;
}
.app-header .meta {
color: var(--muted);
font-size: 12px;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 12px;
}
button {
background: #1f2937;
color: var(--text);
border: 1px solid var(--border);
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
}
button.primary {
background: var(--accent);
color: #052e16;
border: none;
}
button.danger {
background: var(--danger);
color: #fff;
border: none;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.status {
margin-left: 8px;
color: var(--muted);
}
.status.success {
color: var(--accent);
}
.status.error {
color: var(--danger);
}
.rules {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.rules-head,
.rule-row {
display: grid;
grid-template-columns: 1.2fr 1.5fr 1.2fr 1fr 1fr 0.6fr 0.6fr;
gap: 8px;
}
.rules-head {
background: #0b1220;
padding: 10px;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-size: 12px;
}
.rule-row {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.rule-row:last-child {
border-bottom: none;
}
input,
select,
textarea {
width: 100%;
background: #0b1220;
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
}
textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
/* Textareas en filas de reglas: compactos (2 líneas iniciales) */
.rules .comment,
.rules .pattern {
min-height: 0;
line-height: 1.3;
}
/* JSON crudo amplio por defecto */
#rawJson {
min-height: 220px;
}
/* Textareas del modal con altura cómoda */
.modal textarea {
min-height: 120px;
}
/* Replacement en modal puede ser más bajo */
#mReplacement {
min-height: 60px;
}
.actions {
display: flex;
gap: 6px;
justify-content: center;
}
.advanced {
margin-top: 12px;
}
/* Modal */
.modal.hidden {
display: none;
}
.modal .modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal .modal-dialog {
position: fixed;
top: 6%;
left: 50%;
transform: translateX(-50%);
width: min(1000px, 92vw);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
max-height: 88vh;
display: flex;
flex-direction: column;
}
.modal-header,
.modal-footer {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-footer {
border-top: 1px solid var(--border);
border-bottom: none;
}
.modal-body {
padding: 12px;
overflow: auto;
}
.form-grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px 12px;
align-items: center;
margin-bottom: 10px;
}
.split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
pre {
background: #0b1220;
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
white-space: pre-wrap;
word-break: break-word;
min-height: 160px;
}
.muted {
color: var(--muted);
font-size: 12px;
margin-left: 8px;
}

View File

@ -0,0 +1,102 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Editor de beautify_rules.json</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="app-header">
<h1>Editor de reglas: beautify_rules.json</h1>
<div class="meta" id="metaInfo"></div>
</header>
<main class="container">
<section class="toolbar">
<button id="btnReload">Recargar</button>
<button id="btnAdd">Agregar regla</button>
<button id="btnSave" class="primary">Guardar</button>
<button id="btnShutdown" class="danger">Cerrar servidor</button>
<span id="status" class="status"></span>
</section>
<section class="rules">
<div class="rules-head">
<div>Comentario</div>
<div>Pattern</div>
<div>Replacement</div>
<div>Action</div>
<div>Type</div>
<div>Priority</div>
<div>Acciones</div>
</div>
<div id="rulesList" class="rules-list"></div>
</section>
<details class="advanced">
<summary>Avanzado: JSON completo</summary>
<textarea id="rawJson" spellcheck="false"></textarea>
</details>
</main>
<div id="modal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop"></div>
<div class="modal-dialog" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Editar y Testear Regla</h2>
<button id="modalClose" class="icon"></button>
</div>
<div class="modal-body">
<div class="form-grid">
<label>Pattern</label>
<textarea id="mPattern" rows="4" spellcheck="false"></textarea>
<label>Replacement</label>
<textarea id="mReplacement" rows="2" spellcheck="false"></textarea>
<label>Action</label>
<select id="mAction"></select>
<label>Type</label>
<select id="mType"></select>
</div>
<div class="modal-actions">
<button id="btnModalTest">Probar contra cronologia.md</button>
<span id="modalMeta" class="muted"></span>
</div>
<div class="help-block">
<details>
<summary>Ayuda de acciones</summary>
<p id="mActionHelp" class="muted"></p>
<ul>
<li><strong>replace</strong>: Reemplaza coincidencias del patrón por el replacement.</li>
<li><strong>remove_line</strong>: Elimina líneas que coinciden con el patrón.</li>
<li><strong>remove_block</strong>: Elimina un bloque entre dos marcas usando "....." como
comodín.</li>
<li><strong>add_before</strong>: Inserta el replacement antes de la línea coincidente.</li>
<li><strong>add_after</strong>: Inserta el replacement después de la línea coincidente.</li>
</ul>
</details>
</div>
<div class="split">
<div>
<h3>Original</h3>
<pre id="mOriginal"></pre>
</div>
<div>
<h3>Procesado</h3>
<pre id="mProcessed"></pre>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btnModalApply" class="primary">Aplicar cambios a la regla</button>
<button id="btnModalCancel">Cancelar</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@ -124,7 +124,8 @@ def _html_a_markdown(html, cid_to_link=None):
soup = BeautifulSoup(html, "html.parser")
# Reemplazar imágenes inline referenciadas por cid en su lugar
# Reemplazar imágenes inline referenciadas por cid en su lugar,
# conservando un ancho máximo de 800px
if cid_to_link:
for img in soup.find_all("img"):
src = img.get("src", "")
@ -132,8 +133,45 @@ def _html_a_markdown(html, cid_to_link=None):
cid = src[4:].strip("<>")
embed_path = cid_to_link.get(cid)
if embed_path:
# Obsidian embed (single '!') con ruta relativa al vault
img.replace_with(soup.new_string(f"![[{embed_path}]]"))
# Determinar ancho solicitado
width_attr = img.get("width")
style_attr = img.get("style", "")
width_px = None
if width_attr:
try:
digits = "".join(
ch for ch in str(width_attr) if ch.isdigit()
)
if digits:
width_px = int(digits)
except Exception:
width_px = None
if width_px is None and style_attr:
m = re.search(r"width\s*:\s*(\d+)px", style_attr, re.I)
if m:
try:
width_px = int(m.group(1))
except Exception:
width_px = None
if width_px is None:
m2 = re.search(
r"max-width\s*:\s*(\d+)px", style_attr, re.I
)
if m2:
try:
width_px = int(m2.group(1))
except Exception:
width_px = None
if width_px is None:
width_px = 800
else:
width_px = min(width_px, 800)
# Obsidian embed con modificador de ancho
img.replace_with(
soup.new_string(f"![[{embed_path}|{width_px}]]")
)
# Procesar tablas
for table in soup.find_all("table"):

View File

@ -0,0 +1,503 @@
#!/usr/bin/env python3
"""
Editor de Reglas de Embellecimiento (beautify_rules.json)
Aplicación Flask con UI simple para gestionar las reglas en
`config/beautify_rules.json` (CRUD, reordenamiento y guardado con backup).
"""
import json
import os
import shutil
import threading
import time
from datetime import datetime
from typing import Any, Dict, List
import webbrowser
import sys
import re
import signal
from flask import Flask, jsonify, render_template, request
BASE_DIR = os.path.dirname(__file__)
DATA_PATH = os.path.join(BASE_DIR, "config", "beautify_rules.json")
# Cargar configuración del launcher si está disponible
script_root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
)
sys.path.append(script_root)
try:
from backend.script_utils import load_configuration # type: ignore
HAS_CONFIG = True
except Exception:
HAS_CONFIG = False
def ensure_dirs(app: Flask) -> None:
templates_dir = os.path.join(BASE_DIR, "templates")
static_dir = os.path.join(BASE_DIR, "static")
if not os.path.exists(templates_dir):
os.makedirs(templates_dir, exist_ok=True)
if not os.path.exists(static_dir):
os.makedirs(static_dir, exist_ok=True)
app.template_folder = templates_dir
app.static_folder = static_dir
def load_rules_file() -> Dict[str, Any]:
if not os.path.exists(DATA_PATH):
raise FileNotFoundError(f"No existe el archivo: {DATA_PATH}")
with open(DATA_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def validate_rule(rule: Dict[str, Any]) -> List[str]:
errors: List[str] = []
allowed_actions = {
"replace",
"remove_line",
"remove_block",
"add_before",
"add_after",
}
allowed_types = {"string", "regex", "left", "right", "substring"}
if "pattern" not in rule or not isinstance(rule["pattern"], str):
errors.append("'pattern' es obligatorio y debe ser string")
if "replacement" not in rule or not isinstance(rule["replacement"], str):
msg = "'replacement' es obligatorio y debe ser string " "(puede ser vacío)"
errors.append(msg)
if "action" not in rule or rule["action"] not in allowed_actions:
errors.append(f"'action' debe ser uno de: {sorted(allowed_actions)}")
if "type" not in rule or rule["type"] not in allowed_types:
errors.append(f"'type' debe ser uno de: {sorted(allowed_types)}")
if "priority" not in rule or not isinstance(rule["priority"], int):
errors.append("'priority' es obligatorio y debe ser entero")
if (
"__comment" in rule
and rule["__comment"] is not None
and not isinstance(rule["__comment"], str)
):
errors.append("'__comment' debe ser string si está presente")
return errors
def validate_payload(payload: Dict[str, Any]) -> List[str]:
errors: List[str] = []
if not isinstance(payload, dict):
return ["El payload debe ser un objeto JSON"]
if "rules" not in payload or not isinstance(payload["rules"], list):
return ["El payload debe contener 'rules' como lista"]
for idx, rule in enumerate(payload["rules"]):
if not isinstance(rule, dict):
errors.append(f"Regla #{idx + 1}: debe ser un objeto")
continue
rule_errors = validate_rule(rule)
if rule_errors:
prefix = f"Regla #{idx + 1}: "
errors.extend(prefix + e for e in rule_errors)
return errors
def backup_file(path: str) -> str:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
dirname, filename = os.path.dirname(path), os.path.basename(path)
name, ext = os.path.splitext(filename)
backup_name = f"{name}.backup_{timestamp}{ext}"
backup_path = os.path.join(dirname, backup_name)
shutil.copy2(path, backup_path)
return backup_path
def create_app() -> Flask:
app = Flask(__name__)
ensure_dirs(app)
# Detectar ruta a cronologia.md desde configuración
crono_path: str | None = None
if HAS_CONFIG:
try:
configs = load_configuration()
working_directory = configs.get("working_directory")
group_config = configs.get("level2", {})
cronologia_file = group_config.get("cronologia_file", "cronologia.md")
if working_directory:
crono_path = os.path.join(working_directory, cronologia_file)
except Exception:
crono_path = None
app.config["CRONO_PATH"] = crono_path
# Configuración de auto-cierre por inactividad (segundos)
try:
inactivity_env = os.environ.get("INACTIVITY_TIMEOUT_SECONDS", "60").strip()
inactivity_timeout = int(inactivity_env)
except Exception:
inactivity_timeout = 60
app.config["INACTIVITY_TIMEOUT_SECONDS"] = max(10, inactivity_timeout)
app.config["LAST_HEARTBEAT"] = time.time()
def _request_shutdown() -> None:
# Cierre elegante similar a .example/manager.py
def _do():
time.sleep(0.1)
try:
os.kill(os.getpid(), signal.SIGINT)
except Exception:
os._exit(0)
t = threading.Thread(target=_do, daemon=True)
t.start()
@app.route("/")
def index() -> Any:
return render_template("index.html")
@app.get("/api/rules")
def api_get_rules() -> Any:
try:
data = load_rules_file()
return jsonify({"status": "success", "data": data})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.get("/api/heartbeat")
def api_heartbeat() -> Any:
try:
app.config["LAST_HEARTBEAT"] = time.time()
timeout_seconds = app.config["INACTIVITY_TIMEOUT_SECONDS"]
return jsonify(
{
"status": "success",
"server_time": datetime.now().isoformat(),
"timeout_seconds": timeout_seconds,
}
)
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.post("/api/rules")
def api_save_rules() -> Any:
try:
payload = request.get_json(silent=True)
if payload is None:
return (
jsonify(
{
"status": "error",
"message": "JSON inválido",
}
),
400,
)
errors = validate_payload(payload)
if errors:
return (
jsonify(
{
"status": "error",
"message": "\n".join(errors),
}
),
400,
)
if not os.path.exists(DATA_PATH):
return (
jsonify(
{
"status": "error",
"message": f"No existe {DATA_PATH}",
}
),
404,
)
backup_path = backup_file(DATA_PATH)
with open(DATA_PATH, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=4)
return jsonify(
{
"status": "success",
"message": "Reglas guardadas correctamente",
"backup": backup_path,
}
)
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.get("/api/meta")
def api_meta() -> Any:
try:
data_exists = os.path.exists(DATA_PATH)
size = os.path.getsize(DATA_PATH) if data_exists else 0
mtime = os.path.getmtime(DATA_PATH) if data_exists else None
crono = app.config.get("CRONO_PATH")
crono_exists = os.path.exists(crono) if isinstance(crono, str) else False
return jsonify(
{
"status": "success",
"path": DATA_PATH,
"exists": data_exists,
"size": size,
"modified": (
datetime.fromtimestamp(mtime).isoformat() if mtime else None
),
"cronologia_path": crono,
"cronologia_exists": crono_exists,
}
)
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
# Helpers para test de patrón sobre cronologia.md
def _convert_block_pattern_to_regex(pattern: str) -> re.Pattern[str]:
marker = "__BLOCK_MARKER__"
marked = pattern.replace(".....", marker)
escaped = re.escape(marked)
final_rx = escaped.replace(marker, ".*?")
return re.compile(f"(?s){final_rx}")
def _line_matches(line: str, patt: Any, ptype: str) -> bool:
sline = line.strip()
if ptype == "regex":
return bool(patt.search(sline))
if ptype == "left":
return sline.startswith(patt)
if ptype == "right":
return sline.endswith(patt)
if ptype == "substring":
return patt in sline
if ptype == "string":
return sline == patt
return False
def _apply_replace(text: str, patt: Any, repl: str, ptype: str) -> str:
if ptype == "regex":
return patt.sub(repl, text)
if ptype in ("string", "substring"):
return text.replace(patt, repl)
if ptype == "left":
out: List[str] = []
for ln in text.splitlines():
if ln.strip().startswith(patt):
out.append(ln.replace(patt, repl, 1))
else:
out.append(ln)
return "\n".join(out)
if ptype == "right":
out = []
for ln in text.splitlines():
if ln.strip().endswith(patt):
idx = ln.rindex(patt)
out.append(ln[:idx] + repl + ln[idx + len(patt) :])
else:
out.append(ln)
return "\n".join(out)
return text
def _process_remove_block(text: str, patt: re.Pattern[str]) -> str:
result = text
matches = list(patt.finditer(result))
for m in reversed(matches):
start, end = m.span()
line_start = result.rfind("\n", 0, start) + 1
if line_start == 0:
line_start = 0
line_end = result.find("\n", end)
if line_end == -1:
line_end = len(result)
else:
line_end += 1
while (
line_start > 0
and result[line_start - 1 : line_start] == "\n"
and (line_start == 1 or result[line_start - 2 : line_start - 1] == "\n")
):
line_start -= 1
while (
line_end < len(result)
and result[line_end - 1 : line_end] == "\n"
and (
line_end == len(result) - 1
or result[line_end : line_end + 1] == "\n"
)
):
line_end += 1
result = result[:line_start] + result[line_end:]
return result
def _process_remove_line(text: str, patt: Any, ptype: str) -> str:
lines = text.splitlines()
out: List[str] = []
skip_next_empty = False
for i, ln in enumerate(lines):
if _line_matches(ln, patt, ptype):
if i < len(lines) - 1 and not lines[i + 1].strip():
skip_next_empty = True
continue
if skip_next_empty and not ln.strip():
skip_next_empty = False
continue
out.append(ln)
skip_next_empty = False
return "\n".join(out)
def _process_line_additions(
text: str, patt: Any, repl: str, action: str, ptype: str
) -> str:
lines = text.splitlines()
out: List[str] = []
for ln in lines:
if _line_matches(ln, patt, ptype):
if action == "add_before":
out.append(repl)
out.append(ln)
else:
out.append(ln)
out.append(repl)
else:
out.append(ln)
return "\n".join(out)
@app.post("/api/test_pattern")
def api_test_pattern() -> Any:
try:
crono = app.config.get("CRONO_PATH")
if not isinstance(crono, str) or not os.path.exists(crono):
return (
jsonify(
{
"status": "error",
"message": "cronologia.md no encontrado",
}
),
404,
)
payload = request.get_json(silent=True) or {}
pattern = payload.get("pattern", "")
replacement = payload.get("replacement", "")
action = payload.get("action", "replace")
ptype = payload.get("type", "string")
max_chars = int(payload.get("max_chars", 4000))
with open(crono, "r", encoding="utf-8") as f:
original = f.read()
# Construir patrón efectivo
if action == "remove_block":
patt_obj = _convert_block_pattern_to_regex(pattern)
ptype_eff = "regex"
elif ptype == "regex":
patt_obj = re.compile(pattern)
ptype_eff = "regex"
else:
patt_obj = pattern
ptype_eff = ptype
# Aplicar acción
if action == "replace":
processed = _apply_replace(
original,
patt_obj,
replacement,
ptype_eff,
)
elif action == "remove_line":
processed = _process_remove_line(original, patt_obj, ptype_eff)
elif action in ("add_before", "add_after"):
processed = _process_line_additions(
original,
patt_obj,
replacement,
action,
ptype_eff,
)
elif action == "remove_block":
processed = _process_remove_block(original, patt_obj)
else:
processed = original
def _trim(txt: str, limit: int) -> str:
if len(txt) <= limit:
return txt
return txt[:limit] + "\n... (recortado)"
return jsonify(
{
"status": "success",
"original_excerpt": _trim(original, max_chars),
"processed_excerpt": _trim(processed, max_chars),
"total_len": len(original),
}
)
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.post("/_shutdown")
def shutdown_route() -> Any:
_request_shutdown()
return jsonify({"status": "success", "message": "Cerrando editor..."})
# Lanzar monitor de inactividad
def _inactivity_monitor() -> None:
check_period = 5
timeout = app.config["INACTIVITY_TIMEOUT_SECONDS"]
while True:
time.sleep(check_period)
last = app.config.get("LAST_HEARTBEAT")
if isinstance(last, (int, float)):
if time.time() - float(last) > timeout:
_request_shutdown()
break
threading.Thread(target=_inactivity_monitor, daemon=True).start()
return app
def main() -> None:
app = create_app()
port = 5137
url = f"http://127.0.0.1:{port}"
print("🧩 Editor de Reglas - iniciado")
print(f"Archivo: {DATA_PATH}")
print(f"URL: {url}")
print("Ctrl+C para cerrar")
# Abrir navegador automáticamente (desactivar con AUTO_OPEN_BROWSER=0)
auto_open_env = os.environ.get("AUTO_OPEN_BROWSER", "1").lower()
auto_open_browser = auto_open_env not in ("0", "false", "no")
if auto_open_browser:
def _open_browser() -> None:
time.sleep(0.8)
try:
webbrowser.open(url)
except Exception:
pass
threading.Timer(0.5, _open_browser).start()
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)
if __name__ == "__main__":
main()

View File

@ -1,107 +1,182 @@
[14:55:47] Iniciando ejecución de x1.py en C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098...
[14:55:47] ✅ Configuración cargada exitosamente
[14:55:47] Working/Output directory: C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098
[14:55:47] Input directory: C:\Trabajo\SIDEL\17 - E5.006880 - Modifica O&U - RSC098\Reporte\Emails
[14:55:47] Output file: C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098\cronologia.md
[14:55:47] Attachments directory: C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098\adjuntos
[14:55:47] Beautify rules file: D:\Proyectos\Scripts\ParamManagerScripts\backend\script_groups\EmailCrono\config\beautify_rules.json
[14:55:47] Found 1 .eml files
[14:55:47] Creando cronología nueva (archivo se sobrescribirá)
[14:55:47] ============================================================
[14:55:47] Processing file: C:\Trabajo\SIDEL\17 - E5.006880 - Modifica O&U - RSC098\Reporte\Emails\R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml
[14:55:47] 📧 Abriendo archivo: C:\Trabajo\SIDEL\17 - E5.006880 - Modifica O&U - RSC098\Reporte\Emails\R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml
[14:55:48] ✉️ Mensaje extraído:
[14:55:48] - Subject: R: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] - Remitente: "Passera, Alessandro" <Alessandro.Passera@sidel.com>
[14:55:48] - Fecha: 2025-08-08 07:49:28
[14:55:48] - Adjuntos: 0 archivos
[14:55:48] - Contenido: 5160 caracteres
[14:55:48] - Hash generado: 48f94bf24945f73bc08c1c0cf8c1e8bb
[14:55:48] ✉️ Mensaje extraído:
[14:55:48] - Subject: RE: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] - Remitente: "Bii, Vickodyne" <vickodyne.bii@sidel.com>
[14:55:48] - Fecha: 2025-08-08 05:46:30
[14:55:48] - Adjuntos: 0 archivos
[14:55:48] - Contenido: 4686 caracteres
[14:55:48] - Hash generado: 352a3d37ac274b11822ca5527cd8865b
[14:55:48] ✉️ Mensaje extraído:
[14:55:48] - Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] - Remitente: "walter.orsi@teknors.com" <walter.orsi@teknors.com>
[14:55:48] - Fecha: 2025-08-07 15:55:58
[14:55:48] - Adjuntos: 0 archivos
[14:55:48] - Contenido: 3583 caracteres
[14:55:48] - Hash generado: b2558be8631ba7d14210a4a3379dfdad
[14:55:48] ✉️ Mensaje extraído:
[14:55:48] - Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] - Remitente: "Passera, Alessandro" <Alessandro.Passera@sidel.com>
[14:55:48] - Fecha: 2025-08-07 13:07:11
[14:55:48] - Adjuntos: 0 archivos
[14:55:48] - Contenido: 2485 caracteres
[14:55:48] - Hash generado: dc05b2959920f679cd60e8a29685badc
[14:55:48] ✉️ Mensaje extraído:
[14:55:48] - Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] - Remitente: "Passera, Alessandro" <Alessandro.Passera@sidel.com>
[14:55:48] - Fecha: 2025-08-07 12:59:15
[14:55:48] - Adjuntos: 0 archivos
[14:55:48] - Contenido: 1700 caracteres
[14:55:48] - Hash generado: a848be3351ae2cc44bafb0f322a78690
[14:55:48] ✉️ Mensaje extraído:
[14:55:48] - Subject: RE: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] - Remitente: Miguel Angel Vera <miguelverateknors@gmail.com>
[14:55:48] - Fecha: 2025-08-08 09:41:58
[14:55:48] - Adjuntos: 0 archivos
[14:55:48] - Contenido: 5160 caracteres
[14:55:48] - Hash generado: 430cc918020c3c8db795995baa26cb78
[14:55:48] 📧 Procesamiento completado: 6 mensajes extraídos
[14:55:48] Extracted 6 messages from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml
[14:55:48] --- Msg 1/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[14:55:48] Remitente: Passera, Alessandro
[14:55:48] Fecha: 2025-08-08 07:49:28
[14:55:48] Subject: R: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] Hash: 48f94bf24945f73bc08c1c0cf8c1e8bb
[14:55:48] Adjuntos: []
[14:55:48] ✓ NUEVO mensaje - Agregando a la cronología
[14:55:48] --- Msg 2/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[14:55:48] Remitente: Bii, Vickodyne
[14:55:48] Fecha: 2025-08-08 05:46:30
[14:55:48] Subject: RE: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] Hash: 352a3d37ac274b11822ca5527cd8865b
[14:55:48] Adjuntos: []
[14:55:48] ✓ NUEVO mensaje - Agregando a la cronología
[14:55:48] --- Msg 3/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[14:55:48] Remitente: walter.orsi@teknors.com
[14:55:48] Fecha: 2025-08-07 15:55:58
[14:55:48] Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] Hash: b2558be8631ba7d14210a4a3379dfdad
[14:55:48] Adjuntos: []
[14:55:48] ✓ NUEVO mensaje - Agregando a la cronología
[14:55:48] --- Msg 4/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[14:55:48] Remitente: Passera, Alessandro
[14:55:48] Fecha: 2025-08-07 13:07:11
[14:55:48] Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] Hash: dc05b2959920f679cd60e8a29685badc
[14:55:48] Adjuntos: []
[14:55:48] ✓ NUEVO mensaje - Agregando a la cronología
[14:55:48] --- Msg 5/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[14:55:48] Remitente: Passera, Alessandro
[14:55:48] Fecha: 2025-08-07 12:59:15
[14:55:48] Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] Hash: a848be3351ae2cc44bafb0f322a78690
[14:55:48] Adjuntos: []
[14:55:48] ✓ NUEVO mensaje - Agregando a la cronología
[14:55:48] --- Msg 6/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[14:55:48] Remitente: Miguel Angel Vera
[14:55:48] Fecha: 2025-08-08 09:41:58
[14:55:48] Subject: RE: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[14:55:48] Hash: 430cc918020c3c8db795995baa26cb78
[14:55:48] Adjuntos: []
[14:55:48] ✓ NUEVO mensaje - Agregando a la cronología
[14:55:48] Estadísticas de procesamiento:
[14:55:48] - Total mensajes encontrados: 6
[14:55:48] - Mensajes únicos añadidos: 6
[14:55:48] - Mensajes duplicados ignorados: 0
[14:55:48] Writing 6 messages to C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098\cronologia.md
[14:55:48] ✅ Cronología guardada exitosamente en: C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098\cronologia.md
[14:55:48] 📊 Total de mensajes en la cronología: 6
[14:55:48] Ejecución de x1.py finalizada (success). Duración: 0:00:00.497219.
[14:55:48] Log completo guardado en: D:\Proyectos\Scripts\ParamManagerScripts\backend\script_groups\EmailCrono\.log\log_x1.txt
[16:57:07] Iniciando ejecución de x1.py en C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098...
[16:57:07] ✅ Configuración cargada exitosamente
[16:57:07] Working/Output directory: C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098
[16:57:07] Input directory: C:\Trabajo\SIDEL\17 - E5.006880 - Modifica O&U - RSC098\Reporte\Emails
[16:57:07] Output file: C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098\cronologia.md
[16:57:07] Attachments directory: C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098\adjuntos
[16:57:07] Beautify rules file: D:\Proyectos\Scripts\ParamManagerScripts\backend\script_groups\EmailCrono\config\beautify_rules.json
[16:57:07] Found 2 .eml files
[16:57:07] Creando cronología nueva (archivo se sobrescribirá)
[16:57:07] ============================================================
[16:57:07] Processing file: C:\Trabajo\SIDEL\17 - E5.006880 - Modifica O&U - RSC098\Reporte\Emails\R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml
[16:57:07] 📧 Abriendo archivo: C:\Trabajo\SIDEL\17 - E5.006880 - Modifica O&U - RSC098\Reporte\Emails\R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml
[16:57:07] ✉️ Mensaje extraído:
[16:57:07] - Subject: R: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] - Remitente: "Passera, Alessandro" <Alessandro.Passera@sidel.com>
[16:57:07] - Fecha: 2025-08-08 07:49:28
[16:57:07] - Adjuntos: 0 archivos
[16:57:07] - Contenido: 5180 caracteres
[16:57:07] - Hash generado: 48f94bf24945f73bc08c1c0cf8c1e8bb
[16:57:07] ✉️ Mensaje extraído:
[16:57:07] - Subject: RE: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] - Remitente: "Bii, Vickodyne" <vickodyne.bii@sidel.com>
[16:57:07] - Fecha: 2025-08-08 05:46:30
[16:57:07] - Adjuntos: 0 archivos
[16:57:07] - Contenido: 4707 caracteres
[16:57:07] - Hash generado: 8698a85f79aec51ac60ef2ecf97a0efe
[16:57:07] ✉️ Mensaje extraído:
[16:57:07] - Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] - Remitente: "walter.orsi@teknors.com" <walter.orsi@teknors.com>
[16:57:07] - Fecha: 2025-08-07 15:55:58
[16:57:07] - Adjuntos: 0 archivos
[16:57:07] - Contenido: 3603 caracteres
[16:57:07] - Hash generado: 04c55d7b2397eb7e760069d399f3e0ae
[16:57:07] ✉️ Mensaje extraído:
[16:57:07] - Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] - Remitente: "Passera, Alessandro" <Alessandro.Passera@sidel.com>
[16:57:07] - Fecha: 2025-08-07 13:07:11
[16:57:07] - Adjuntos: 0 archivos
[16:57:07] - Contenido: 2490 caracteres
[16:57:07] - Hash generado: dc05b2959920f679cd60e8a29685badc
[16:57:07] ✉️ Mensaje extraído:
[16:57:07] - Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] - Remitente: "Passera, Alessandro" <Alessandro.Passera@sidel.com>
[16:57:07] - Fecha: 2025-08-07 12:59:15
[16:57:07] - Adjuntos: 0 archivos
[16:57:07] - Contenido: 1705 caracteres
[16:57:07] - Hash generado: a848be3351ae2cc44bafb0f322a78690
[16:57:07] ✉️ Mensaje extraído:
[16:57:07] - Subject: RE: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] - Remitente: Miguel Angel Vera <miguelverateknors@gmail.com>
[16:57:07] - Fecha: 2025-08-08 09:41:58
[16:57:07] - Adjuntos: 0 archivos
[16:57:07] - Contenido: 5180 caracteres
[16:57:07] - Hash generado: 430cc918020c3c8db795995baa26cb78
[16:57:07] 📧 Procesamiento completado: 6 mensajes extraídos
[16:57:07] Extracted 6 messages from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml
[16:57:07] --- Msg 1/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[16:57:07] Remitente: Passera, Alessandro
[16:57:07] Fecha: 2025-08-08 07:49:28
[16:57:07] Subject: R: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] Hash: 48f94bf24945f73bc08c1c0cf8c1e8bb
[16:57:07] Adjuntos: []
[16:57:07] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:07] --- Msg 2/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[16:57:07] Remitente: Bii, Vickodyne
[16:57:07] Fecha: 2025-08-08 05:46:30
[16:57:07] Subject: RE: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] Hash: 8698a85f79aec51ac60ef2ecf97a0efe
[16:57:07] Adjuntos: []
[16:57:07] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:07] --- Msg 3/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[16:57:07] Remitente: walter.orsi@teknors.com
[16:57:07] Fecha: 2025-08-07 15:55:58
[16:57:07] Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] Hash: 04c55d7b2397eb7e760069d399f3e0ae
[16:57:07] Adjuntos: []
[16:57:07] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:07] --- Msg 4/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[16:57:07] Remitente: Passera, Alessandro
[16:57:07] Fecha: 2025-08-07 13:07:11
[16:57:07] Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] Hash: dc05b2959920f679cd60e8a29685badc
[16:57:07] Adjuntos: []
[16:57:07] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:07] --- Msg 5/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[16:57:07] Remitente: Passera, Alessandro
[16:57:07] Fecha: 2025-08-07 12:59:15
[16:57:07] Subject: R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] Hash: a848be3351ae2cc44bafb0f322a78690
[16:57:07] Adjuntos: []
[16:57:07] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:07] --- Msg 6/6 from R_ E5.006880 - RSC098 - Nigerian Breweries_ URGENT.eml ---
[16:57:07] Remitente: Miguel Angel Vera
[16:57:07] Fecha: 2025-08-08 09:41:58
[16:57:07] Subject: RE: {EXT} R: E5.006880 - RSC098 - Nigerian Breweries: URGENT
[16:57:07] Hash: 430cc918020c3c8db795995baa26cb78
[16:57:07] Adjuntos: []
[16:57:07] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:07] ============================================================
[16:57:07] Processing file: C:\Trabajo\SIDEL\17 - E5.006880 - Modifica O&U - RSC098\Reporte\Emails\{EXT} R E5.006880 - RSC098 - Nigerian Breweries URGENT.eml
[16:57:07] 📧 Abriendo archivo: C:\Trabajo\SIDEL\17 - E5.006880 - Modifica O&U - RSC098\Reporte\Emails\{EXT} R E5.006880 - RSC098 - Nigerian Breweries URGENT.eml
[16:57:08] ✉️ Mensaje extraído:
[16:57:08] - Subject: RE: 25-128241 - Filler Regular/Mixer/CIP - SRSC0098 / HMI backup issue E5.007940 / CRM:0153426
[16:57:08] - Remitente: "Tadina, Rodindo" <Rodindo.Tadina@sidel.com>
[16:57:08] - Fecha: 2025-08-05 17:31:36
[16:57:08] - Adjuntos: 0 archivos
[16:57:08] - Contenido: 13270 caracteres
[16:57:08] - Hash generado: bdedfb1a503b9031505c1a1d35b8ddac
[16:57:08] ✉️ Mensaje extraído:
[16:57:08] - Subject: Automatic reply: 25-128241 - Filler Regular/Mixer/CIP - SRSC0098 / HMI backup issue E5.007940 / CRM:0153426
[16:57:08] - Remitente: "Bii, Vickodyne" <vickodyne.bii@sidel.com>
[16:57:08] - Fecha: 2025-08-05 09:43:27
[16:57:08] - Adjuntos: 0 archivos
[16:57:08] - Contenido: 442 caracteres
[16:57:08] - Hash generado: b1162c3cd6ca2654cb73eb1c4eb63d66
[16:57:08] ✉️ Mensaje extraído:
[16:57:08] - Subject: RE: 25-128241 - Filler Regular/Mixer/CIP - SRSC0098 / HMI backup issue E5.007940 / CRM:0153426
[16:57:08] - Remitente: Miguel Angel Vera <miguelverateknors@gmail.com>
[16:57:08] - Fecha: 2025-08-05 09:42:59
[16:57:08] - Adjuntos: 0 archivos
[16:57:08] - Contenido: 12358 caracteres
[16:57:08] - Hash generado: 84e2e736041591d6d557eef39f8ed2fd
[16:57:08] ✉️ Mensaje extraído:
[16:57:08] - Subject: RE: 25-128241 - Filler Regular/Mixer/CIP - SRSC0098 / HMI backup issue E5.007940 / CRM:0153426
[16:57:08] - Remitente: "Rizzello, Roberto" <Roberto.Rizzello@sidel.com>
[16:57:08] - Fecha: 2025-08-05 07:11:35
[16:57:08] - Adjuntos: 0 archivos
[16:57:08] - Contenido: 10765 caracteres
[16:57:08] - Hash generado: 103374e0c9a4c3346f6ca8f5df933545
[16:57:08] ✉️ Mensaje extraído:
[16:57:08] - Subject:
[16:57:08] - Remitente: Miguel Angel Vera <miguelverateknors@gmail.com>
[16:57:08] - Fecha: 2025-08-08 09:45:00
[16:57:08] - Adjuntos: 0 archivos
[16:57:08] - Contenido: 140 caracteres
[16:57:08] - Hash generado: c6d44ba931d266d3ef2f1a4ca0f9e1ce
[16:57:08] 📧 Procesamiento completado: 5 mensajes extraídos
[16:57:08] Extracted 5 messages from {EXT} R E5.006880 - RSC098 - Nigerian Breweries URGENT.eml
[16:57:08] --- Msg 1/5 from {EXT} R E5.006880 - RSC098 - Nigerian Breweries URGENT.eml ---
[16:57:08] Remitente: Tadina, Rodindo
[16:57:08] Fecha: 2025-08-05 17:31:36
[16:57:08] Subject: RE: 25-128241 - Filler Regular/Mixer/CIP - SRSC0098 / HMI backup issue E5.007940 / CRM:0153426
[16:57:08] Hash: bdedfb1a503b9031505c1a1d35b8ddac
[16:57:08] Adjuntos: []
[16:57:08] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:08] --- Msg 2/5 from {EXT} R E5.006880 - RSC098 - Nigerian Breweries URGENT.eml ---
[16:57:08] Remitente: Bii, Vickodyne
[16:57:08] Fecha: 2025-08-05 09:43:27
[16:57:08] Subject: Automatic reply: 25-128241 - Filler Regular/Mixer/CIP - SRSC0098 / HMI backup issue E5.007940 / CRM:0153426
[16:57:08] Hash: b1162c3cd6ca2654cb73eb1c4eb63d66
[16:57:08] Adjuntos: []
[16:57:08] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:08] --- Msg 3/5 from {EXT} R E5.006880 - RSC098 - Nigerian Breweries URGENT.eml ---
[16:57:08] Remitente: Miguel Angel Vera
[16:57:08] Fecha: 2025-08-05 09:42:59
[16:57:08] Subject: RE: 25-128241 - Filler Regular/Mixer/CIP - SRSC0098 / HMI backup issue E5.007940 / CRM:0153426
[16:57:08] Hash: 84e2e736041591d6d557eef39f8ed2fd
[16:57:08] Adjuntos: []
[16:57:08] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:08] --- Msg 4/5 from {EXT} R E5.006880 - RSC098 - Nigerian Breweries URGENT.eml ---
[16:57:08] Remitente: Rizzello, Roberto
[16:57:08] Fecha: 2025-08-05 07:11:35
[16:57:08] Subject: RE: 25-128241 - Filler Regular/Mixer/CIP - SRSC0098 / HMI backup issue E5.007940 / CRM:0153426
[16:57:08] Hash: 103374e0c9a4c3346f6ca8f5df933545
[16:57:08] Adjuntos: []
[16:57:08] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:08] --- Msg 5/5 from {EXT} R E5.006880 - RSC098 - Nigerian Breweries URGENT.eml ---
[16:57:08] Remitente: Miguel Angel Vera
[16:57:08] Fecha: 2025-08-08 09:45:00
[16:57:08] Subject: Sin Asunto
[16:57:08] Hash: c6d44ba931d266d3ef2f1a4ca0f9e1ce
[16:57:08] Adjuntos: []
[16:57:08] ✓ NUEVO mensaje - Agregando a la cronología
[16:57:08] Estadísticas de procesamiento:
[16:57:08] - Total mensajes encontrados: 11
[16:57:08] - Mensajes únicos añadidos: 11
[16:57:08] - Mensajes duplicados ignorados: 0
[16:57:08] Writing 11 messages to C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098\cronologia.md
[16:57:08] ✅ Cronología guardada exitosamente en: C:\Users\migue\OneDrive\Miguel\Obsidean\Trabajo\VM\04-SIDEL\17 - E5.006880 - Modifica O&U - RSC098\cronologia.md
[16:57:08] 📊 Total de mensajes en la cronología: 11
[16:57:08] Ejecución de x1.py finalizada (success). Duración: 0:00:01.371817.
[16:57:08] Log completo guardado en: D:\Proyectos\Scripts\ParamManagerScripts\backend\script_groups\EmailCrono\.log\log_x1.txt