Refactor(ADN): Optimización de la arquitectura y reparación de subcomandos
- Reestructuración del CLI en módulos discretos (core/, comando/, config/, clientes/) - Migración del punto de entrada tools/adn.rb hacia el launcher ejecutable tools/adn/run - Simplificación de nombres de comandos (remoción prefijo subcomando_) - Corrección de rutas base transversales (PROJECT_ROOT, BITACORAS_DIR) - Reescritura integral de sentencias require_relative - Restauración de sintaxis en archivo sync.rb truncado
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# tools/adn/clients/dtic_bitacoras_api.rb — Cliente Ruby para API de dtic-BITACORAs
|
||||
# ===============================================================================
|
||||
# Este cliente permite interactuar con la API REST de dtic-BITACORAs desde Ruby.
|
||||
# Proporciona métodos para operaciones CRUD en nodos, bitácoras, entradas y proyectos.
|
||||
#
|
||||
# Configuración:
|
||||
# export DTIC_API_URL=http://localhost:3002
|
||||
# o configurar en tools/adn/config.yml
|
||||
#
|
||||
# Uso básico:
|
||||
# require_relative 'clients/dtic_bitacoras_api'
|
||||
# client = DticBitacorasApi::Client.new
|
||||
# bitacoras = client.listar_bitacoras
|
||||
# client.crear_entrada(...)
|
||||
#
|
||||
# Sincronización:
|
||||
# client.sincronizar_md('bitacoras/2026-03-05.md')
|
||||
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require 'uri'
|
||||
require 'date'
|
||||
require 'yaml'
|
||||
|
||||
module DticBitacorasApi
|
||||
# Excepciones personalizadas
|
||||
class ApiError < StandardError
|
||||
attr_reader :status_code, :response_body
|
||||
|
||||
def initialize(message, status_code = nil, response_body = nil)
|
||||
super(message)
|
||||
@status_code = status_code
|
||||
@response_body = response_body
|
||||
end
|
||||
|
||||
def to_s
|
||||
msg = super
|
||||
msg += " (Status: #{@status_code})" if @status_code
|
||||
msg += "\nResponse: #{@response_body}" if @response_body
|
||||
msg
|
||||
end
|
||||
end
|
||||
|
||||
class ConfigError < StandardError; end
|
||||
|
||||
# Cliente principal
|
||||
class Client
|
||||
# Inicializa el cliente con configuración
|
||||
#
|
||||
# @param options [Hash] Opciones de configuración
|
||||
# @option options [String] :base_url URL base de la API (ej: http://localhost:3002)
|
||||
# @option options [String] :config_path Ruta al archivo de configuración YAML
|
||||
# @option options [Logger] :logger Instancia de logger (opcional)
|
||||
# @option options [Integer] :timeout_seconds Timeout para requests HTTP (default: 30)
|
||||
def initialize(options = {})
|
||||
@config_path = options[:config_path] || File.join(__dir__, '..', 'config.yml')
|
||||
@base_url = options[:base_url] || extraer_url_desde_config
|
||||
@logger = options[:logger] || ADN.logger rescue nil
|
||||
@timeout_seconds = options[:timeout_seconds] || 30
|
||||
|
||||
# Validar URL base
|
||||
@base_url = @base_url.chomp('/')
|
||||
unless @base_url.start_with?('http://', 'https://')
|
||||
raise ConfigError, "URL base debe comenzar con http:// o https://: #{@base_url}"
|
||||
end
|
||||
|
||||
@logger&.info("Cliente dtic-BITACORAs inicializado para #{@base_url}")
|
||||
end
|
||||
|
||||
# --- MÉTODOS PARA NODOS ---
|
||||
|
||||
# Lista todos los nodos activos
|
||||
#
|
||||
# @return [Array<Hash>] Lista de nodos
|
||||
def listar_nodos
|
||||
get('/api/nodos')
|
||||
end
|
||||
|
||||
# Obtiene un nodo por nombre
|
||||
#
|
||||
# @param nombre [String] Nombre del nodo
|
||||
# @return [Hash] Datos del nodo
|
||||
def obtener_nodo_por_nombre(nombre)
|
||||
nodos = listar_nodos
|
||||
nodos.find { |n| n['nombre'] == nombre }
|
||||
end
|
||||
|
||||
# Crea un nuevo nodo
|
||||
#
|
||||
# @param nombre [String] Nombre del nodo
|
||||
# @param ip [String] Dirección IP
|
||||
# @param tipo [String] Tipo de nodo (servidor, vm, pc, hipervisor)
|
||||
# @param descripcion [String] Descripción opcional
|
||||
# @return [Hash] Nodo creado
|
||||
def crear_nodo(nombre, ip: nil, tipo: 'servidor', descripcion: '')
|
||||
post('/api/nodos', {
|
||||
nombre: nombre,
|
||||
ip: ip,
|
||||
tipo: tipo,
|
||||
descripcion: descripcion
|
||||
})
|
||||
end
|
||||
|
||||
# --- MÉTODOS PARA BITÁCORAS ---
|
||||
|
||||
# Lista las últimas bitácoras (por defecto 30 días)
|
||||
#
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @return [Array<Hash>] Lista de bitácoras
|
||||
def listar_bitacoras(limit: 30)
|
||||
get("/api/bitacoras?limit=#{limit}")
|
||||
end
|
||||
|
||||
# Obtiene o crea una bitácora para una fecha específica
|
||||
#
|
||||
# @param fecha [String, Date] Fecha en formato YYYY-MM-DD
|
||||
# @return [Hash] Bitácora
|
||||
def obtener_bitacora(fecha)
|
||||
fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s
|
||||
get("/api/bitacoras?fecha=#{fecha_str}")
|
||||
end
|
||||
|
||||
# Obtiene bitácora completa con entradas agrupadas por nodo
|
||||
#
|
||||
# @param fecha [String, Date] Fecha en formato YYYY-MM-DD
|
||||
# @return [Hash] Bitácora completa con nodos y entradas
|
||||
def obtener_bitacora_completa(fecha)
|
||||
fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s
|
||||
get("/api/bitacoras/#{fecha_str}/completa")
|
||||
end
|
||||
|
||||
# Exporta bitácora a formato Markdown
|
||||
#
|
||||
# @param fecha [String, Date] Fecha en formato YYYY-MM-DD
|
||||
# @return [String] Contenido Markdown
|
||||
def exportar_bitacora_md(fecha)
|
||||
fecha_str = fecha.respond_to?(:strftime) ? fecha.strftime('%Y-%m-%d') : fecha.to_s
|
||||
get_raw("/api/bitacoras/#{fecha_str}/export")
|
||||
end
|
||||
|
||||
# --- MÉTODOS PARA ENTRADAS ---
|
||||
|
||||
# Lista entradas con filtros opcionales
|
||||
#
|
||||
# @param bitacora_id [Integer] ID de bitácora para filtrar
|
||||
# @param nodo_id [Integer] ID de nodo para filtrar
|
||||
# @return [Array<Hash>] Lista de entradas
|
||||
def listar_entradas(bitacora_id: nil, nodo_id: nil)
|
||||
params = []
|
||||
params << "bitacora_id=#{bitacora_id}" if bitacora_id
|
||||
params << "nodo_id=#{nodo_id}" if nodo_id
|
||||
query = params.empty? ? '' : "?#{params.join('&')}"
|
||||
get("/api/entradas#{query}")
|
||||
end
|
||||
|
||||
# Crea una nueva entrada
|
||||
#
|
||||
# @param inicio [String] Hora de inicio (HH:MM)
|
||||
# @param fin [String, nil] Hora de fin (HH:MM) o nil si en curso
|
||||
# @param descripcion [String] Descripción de la actividad
|
||||
# @param bitacora_id [Integer] ID de la bitácora
|
||||
# @param nodo_id [Integer] ID del nodo
|
||||
# @param estado [String] Estado (✅, ⏳, ⚠️, ❌)
|
||||
# @param modo [String] Modo (P: Presencial, R: Remoto)
|
||||
# @param es_ia [Boolean] Si la acción fue realizada por IA
|
||||
# @return [Hash] Entrada creada
|
||||
def crear_entrada(inicio:, fin: nil, descripcion:, bitacora_id:, nodo_id:,
|
||||
estado: '⏳', modo: 'P', es_ia: false)
|
||||
post('/api/entradas', {
|
||||
inicio: inicio,
|
||||
fin: fin,
|
||||
descripcion: descripcion,
|
||||
estado: estado,
|
||||
modo: modo,
|
||||
es_ia: es_ia,
|
||||
bitacora_id: bitacora_id,
|
||||
nodo_id: nodo_id
|
||||
})
|
||||
end
|
||||
|
||||
# Actualiza una entrada existente
|
||||
#
|
||||
# @param id [Integer] ID de la entrada
|
||||
# @param attributes [Hash] Atributos a actualizar
|
||||
# @return [Hash] Entrada actualizada
|
||||
def actualizar_entrada(id, attributes)
|
||||
put("/api/entradas/#{id}", attributes)
|
||||
end
|
||||
|
||||
# --- MÉTODOS PARA PROYECTOS ---
|
||||
|
||||
# Lista todos los proyectos
|
||||
#
|
||||
# @return [Array<Hash>] Lista de proyectos
|
||||
def listar_proyectos
|
||||
get('/api/proyectos')
|
||||
end
|
||||
|
||||
# Obtiene un proyecto por código con detalles completos
|
||||
#
|
||||
# @param codigo [String] Código del proyecto (ej: P2601)
|
||||
# @return [Hash] Proyecto con fases, hitos y métricas
|
||||
def obtener_proyecto(codigo)
|
||||
get("/api/proyectos/#{codigo}")
|
||||
end
|
||||
|
||||
# Crea un hito en un proyecto
|
||||
#
|
||||
# @param codigo_proyecto [String] Código del proyecto
|
||||
# @param fase_numero [Integer] Número de fase
|
||||
# @param id_hito [String] ID del hito (ej: SINC01)
|
||||
# @param titulo [String] Título del hito
|
||||
# @param options [Hash] Opciones adicionales
|
||||
# @option options [String] :descripcion Descripción del hito
|
||||
# @option options [String] :estado Estado del hito (✅, ⏳, 📍)
|
||||
# @option options [String] :fecha Fecha del hito (YYYY-MM-DD)
|
||||
# @option options [Integer] :horas_presencial Horas presenciales
|
||||
# @option options [Integer] :horas_remoto Horas remotas
|
||||
# @option options [String] :nodo_nombre Nombre del nodo asociado
|
||||
# @return [Hash] Hito creado
|
||||
def crear_hito(codigo_proyecto, fase_numero, id_hito, titulo, options = {})
|
||||
post("/api/proyectos/#{codigo_proyecto}/hitos", {
|
||||
fase_numero: fase_numero,
|
||||
id_hito: id_hito,
|
||||
titulo: titulo,
|
||||
descripcion: options[:descripcion] || '',
|
||||
estado: options[:estado] || '⏳',
|
||||
fecha: options[:fecha],
|
||||
horas_presencial: options[:horas_presencial] || 0,
|
||||
horas_remoto: options[:horas_remoto] || 0,
|
||||
nodo_nombre: options[:nodo_nombre]
|
||||
})
|
||||
end
|
||||
|
||||
# --- SINCRONIZACIÓN DE ARCHIVOS MD ---
|
||||
|
||||
# Sincroniza un archivo Markdown con la base de datos
|
||||
#
|
||||
# @param ruta_md [String] Ruta al archivo .md
|
||||
# @param options [Hash] Opciones de sincronización
|
||||
# @option options [Boolean] :sobrescribir Sobrescribir entradas existentes
|
||||
# @return [Hash] Resultado de la sincronización
|
||||
def sincronizar_md(ruta_md, options = {})
|
||||
unless File.exist?(ruta_md)
|
||||
raise ApiError, "Archivo no encontrado: #{ruta_md}"
|
||||
end
|
||||
|
||||
contenido = File.read(ruta_md, encoding: 'UTF-8')
|
||||
nombre_archivo = File.basename(ruta_md)
|
||||
|
||||
# Extraer fecha del nombre del archivo
|
||||
match = nombre_archivo.match(/(\d{4}-\d{2}-\d{2})\.md/)
|
||||
unless match
|
||||
raise ApiError, "Nombre de archivo no sigue formato YYYY-MM-DD.md: #{nombre_archivo}"
|
||||
end
|
||||
|
||||
fecha = match[1]
|
||||
@logger&.info("Sincronizando bitácora: #{fecha} desde #{ruta_md}")
|
||||
|
||||
# Obtener o crear bitácora
|
||||
bitacora = obtener_bitacora(fecha)
|
||||
bitacora_id = bitacora['id']
|
||||
|
||||
# Obtener mapeo de nodos por nombre
|
||||
nodos = listar_nodos
|
||||
nodo_map = {}
|
||||
nodos.each { |n| nodo_map[n['nombre']] = n['id'] }
|
||||
|
||||
# Si sobrescribir, eliminar entradas existentes
|
||||
if options[:sobrescribir]
|
||||
entradas = listar_entradas(bitacora_id: bitacora_id)
|
||||
entradas.each do |entrada|
|
||||
# Podríamos implementar delete si la API lo soporta
|
||||
@logger&.debug("Entrada existente que sería sobrescrita: #{entrada['id']}")
|
||||
end
|
||||
end
|
||||
|
||||
# Parsear contenido MD
|
||||
entradas_encontradas = []
|
||||
nodo_actual_id = nil
|
||||
en_seccion_detalladas = false
|
||||
|
||||
contenido.each_line do |linea|
|
||||
linea = linea.chomp
|
||||
|
||||
# Detectar inicio de sección de actividades detalladas
|
||||
if linea.include?('Actividades Detalladas')
|
||||
en_seccion_detalladas = true
|
||||
next
|
||||
end
|
||||
|
||||
next unless en_seccion_detalladas
|
||||
|
||||
# Detectar cambio de nodo
|
||||
if linea.start_with?('### ')
|
||||
nombre_nodo = linea.sub('### ', '').strip.split(' ')[0]
|
||||
nodo_actual_id = nodo_map[nombre_nodo]
|
||||
@logger&.debug("Cambiando a nodo: #{nombre_nodo} (ID: #{nodo_actual_id})") if nodo_actual_id
|
||||
next
|
||||
end
|
||||
|
||||
next unless nodo_actual_id
|
||||
|
||||
# Parsear entrada de tabla I-F-D-E
|
||||
entrada = parsear_linea_tabla(linea)
|
||||
if entrada
|
||||
entrada[:bitacora_id] = bitacora_id
|
||||
entrada[:nodo_id] = nodo_actual_id
|
||||
|
||||
begin
|
||||
entrada_creada = crear_entrada(entrada)
|
||||
entradas_encontradas << entrada_creada
|
||||
@logger&.debug("Entrada creada: #{entrada[:inicio]} - #{entrada_creada['id']}")
|
||||
rescue ApiError => e
|
||||
@logger&.error("Error creando entrada: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
fecha: fecha,
|
||||
bitacora_id: bitacora_id,
|
||||
entradas_procesadas: entradas_encontradas.size,
|
||||
entradas: entradas_encontradas
|
||||
}
|
||||
end
|
||||
|
||||
# --- MÉTODOS DE UTILIDAD ---
|
||||
|
||||
# Verifica salud de la API
|
||||
#
|
||||
# @return [Hash] Estado de la API
|
||||
def salud
|
||||
get('/health')
|
||||
end
|
||||
|
||||
# Obtiene estadísticas del sistema
|
||||
#
|
||||
# @return [Hash] Estadísticas
|
||||
def estadisticas
|
||||
{
|
||||
nodos: listar_nodos.size,
|
||||
bitacoras: listar_bitacoras.size,
|
||||
proyectos: listar_proyectos.size
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extrae URL base desde archivo de configuración
|
||||
def extraer_url_desde_config
|
||||
return 'http://localhost:3002' unless File.exist?(@config_path)
|
||||
|
||||
begin
|
||||
config = YAML.load_file(@config_path)
|
||||
|
||||
# Intentar obtener URL de dtic-BITACORAs
|
||||
if config['dtic_bitacoras'] && config['dtic_bitacoras']['api_url']
|
||||
return config['dtic_bitacoras']['api_url']
|
||||
end
|
||||
|
||||
# Fallback a puertos predeterminados
|
||||
'http://localhost:3002'
|
||||
rescue => e
|
||||
@logger&.error("Error cargando configuración: #{e.message}")
|
||||
'http://localhost:3002'
|
||||
end
|
||||
end
|
||||
|
||||
# Parsear línea de tabla I-F-D-E
|
||||
def parsear_linea_tabla(linea)
|
||||
# Formato: | HH:MM | HH:MM | Descripción | [P/R] Estado |
|
||||
regex = /\|\s*(\d{1,2}:\d{2})\s*\|\s*(\d{1,2}:\d{2}|\-)\s*\|\s*(.*?)\s*\|\s*(.*?)\s*\|/
|
||||
match = linea.match(regex)
|
||||
return nil unless match
|
||||
|
||||
inicio = match[1]
|
||||
fin = match[2] == '-' ? nil : match[2]
|
||||
descripcion_raw = match[3]
|
||||
estado_raw = match[4]
|
||||
|
||||
# Detectar si es acción de IA
|
||||
es_ia = descripcion_raw.include?('(IA)')
|
||||
descripcion = descripcion_raw
|
||||
.gsub('(IA)', '')
|
||||
.gsub(/\*\*/, '')
|
||||
.gsub(/__/, '')
|
||||
.strip
|
||||
|
||||
# Parsear modo y estado
|
||||
modo = 'P' # Default presencial
|
||||
estado = estado_raw
|
||||
|
||||
if estado_raw.match(/\[([PR])\]\s*(.+)/)
|
||||
modo = $1
|
||||
estado = $2
|
||||
end
|
||||
|
||||
{
|
||||
inicio: inicio,
|
||||
fin: fin,
|
||||
descripcion: descripcion,
|
||||
estado: estado,
|
||||
modo: modo,
|
||||
es_ia: es_ia
|
||||
}
|
||||
end
|
||||
|
||||
# Métodos HTTP genéricos
|
||||
|
||||
def get(path)
|
||||
request(:get, path)
|
||||
end
|
||||
|
||||
def get_raw(path)
|
||||
request_raw(:get, path)
|
||||
end
|
||||
|
||||
def post(path, data)
|
||||
request(:post, path, data)
|
||||
end
|
||||
|
||||
def put(path, data)
|
||||
request(:put, path, data)
|
||||
end
|
||||
|
||||
def request(method, path, data = nil)
|
||||
response = request_raw(method, path, data)
|
||||
JSON.parse(response)
|
||||
rescue JSON::ParserError => e
|
||||
raise ApiError.new("Error parseando respuesta JSON: #{e.message}", nil, response)
|
||||
end
|
||||
|
||||
def request_raw(method, path, data = nil)
|
||||
uri = URI.join(@base_url, path)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.read_timeout = @timeout_seconds
|
||||
http.open_timeout = @timeout_seconds
|
||||
|
||||
request_class = case method
|
||||
when :get then Net::HTTP::Get
|
||||
when :post then Net::HTTP::Post
|
||||
when :put then Net::HTTP::Put
|
||||
when :delete then Net::HTTP::Delete
|
||||
else raise "Método HTTP no soportado: #{method}"
|
||||
end
|
||||
|
||||
request = request_class.new(uri.request_uri)
|
||||
request['Content-Type'] = 'application/json'
|
||||
request['User-Agent'] = "dtic-bitacoras-ruby-client/1.0"
|
||||
|
||||
request.body = data.to_json if data && !data.empty?
|
||||
|
||||
@logger&.debug("#{method.upcase} #{uri} #{data ? "con datos: #{data.inspect}" : ''}")
|
||||
|
||||
begin
|
||||
response = http.request(request)
|
||||
rescue Net::ReadTimeout => e
|
||||
raise ApiError.new("Timeout después de #{@timeout_seconds} segundos: #{e.message}")
|
||||
rescue SocketError, Errno::ECONNREFUSED => e
|
||||
raise ApiError.new("No se puede conectar a #{@base_url}: #{e.message}")
|
||||
end
|
||||
|
||||
@logger&.debug("Respuesta: #{response.code} #{response.message}")
|
||||
|
||||
unless response.is_a?(Net::HTTPSuccess)
|
||||
error_message = "Error #{response.code}: #{response.message}"
|
||||
begin
|
||||
error_body = JSON.parse(response.body)
|
||||
error_message += " - #{error_body['error']}" if error_body['error']
|
||||
rescue JSON::ParserError
|
||||
error_message += " - #{response.body[0..100]}"
|
||||
end
|
||||
|
||||
raise ApiError.new(error_message, response.code, response.body)
|
||||
end
|
||||
|
||||
response.body
|
||||
end
|
||||
end
|
||||
|
||||
# Helper para uso rápido
|
||||
def self.client(options = {})
|
||||
@client ||= Client.new(options)
|
||||
end
|
||||
end
|
||||
|
||||
# Si se ejecuta directamente como script
|
||||
if __FILE__ == $0
|
||||
begin
|
||||
client = DticBitacorasApi::Client.new
|
||||
puts "🧪 Probando conexión a #{client.instance_variable_get(:@base_url)}"
|
||||
|
||||
# Prueba de salud
|
||||
salud = client.salud
|
||||
puts "✅ API saludable: #{salud['status']}"
|
||||
|
||||
# Listar nodos
|
||||
nodos = client.listar_nodos
|
||||
puts "📊 Nodos encontrados: #{nodos.size}"
|
||||
|
||||
# Listar bitácoras
|
||||
bitacoras = client.listar_bitacoras(limit: 5)
|
||||
puts "📅 Últimas bitácoras: #{bitacoras.map { |b| b['fecha'] }.join(', ')}"
|
||||
|
||||
rescue DticBitacorasApi::ApiError => e
|
||||
puts "❌ Error de API: #{e.message}"
|
||||
exit 1
|
||||
rescue DticBitacorasApi::ConfigError => e
|
||||
puts "❌ Error de configuración: #{e.message}"
|
||||
puts " Configura con: export DTIC_API_URL=http://localhost:3002"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user