# frozen_string_literal: true # adn/tools/db/importers/md_importer.rb — Importador de bitácoras .md a PostgreSQL # ================================================================================ # Esta clase convierte bitácoras en formato Markdown al esquema de base de datos # dtic-BITACORAs. Procesa la estructura jerárquica: # Archivo .md → Bitácora → Nodos → Temas → Entradas (I-F-D-E) # # Formato de entrada (.md): # # Bitácora 2026-02-28 # ## Control de Gestión # ### 📝 Resumen de Actividades # | NODO | RESUMEN INTEGRAL | # | :--- | :--- | # | srv-ns8 | Resumen del día... | # # ## 📂 Actividades Detalladas # ### srv-ns8 # #### ✅ - SEC01 - Refactorización ns8-candados # | I | F | Descripción | E | # | :--- | :--- | :--- | :--- | # | 08:00 | 08:34 | Refactorización ns8-candados... | [P] ✅ | # # Uso: # importer = MDImporter.new(db_connection) # resultado = importer.importar_bitacora('bitacoras/2026-02-28.md') # resultado = importer.importar_todo_directorio('bitacoras/') # # Dependencias: Clase BitacoraDB del módulo BitacorasDB require 'date' require 'time' require 'yaml' require 'digest' module BitacorasDB module Importers class MDImporter # Estados posibles en archivos .md y su mapeo a BD ESTADOS_MD = { '✅' => '✅', '❌' => '❌', '⚠️' => '⚠️', '⏸️' => '⏸️', '⏳' => '⏳', '👁️' => '⏳', # Sincronización mapea a ⏳ '➡️' => '⏳', # Tarea continúa en otro día '🎭' => '✅' # Evento/Servicio }.freeze # Modos posibles en archivos .md MODOS_MD = { '[P]' => 'P', # Presencial '[R]' => 'R', # Remoto '[P/R]' => 'P', # Presencial/Remoto -> Presencial por defecto '[S]' => 'S' # Servicio (No nodo técnico) }.freeze # Tipos de nodo inferidos del nombre TIPOS_NODO = { /^srv-/ => 'servidor', /^dc-/ => 'vm', /^sql-/ => 'vm', /^pcv-/ => 'vm', /^pc-/ => 'pc', /^srvv-/ => 'vm', /^dtic-/ => 'servicio', /-pmox\d+$/ => 'hipervisor', /-xen\d+$/ => 'hipervisor' }.freeze # Inicializa el importador con conexión a base de datos # # @param db [BitacoraDB] Instancia de BitacoraDB para operaciones # @param logger [Object] Logger para registro (opcional) def initialize(db, logger = nil) @db = db @logger = logger || default_logger @estadisticas = { archivos_procesados: 0, bitacoras_creadas: 0, nodos_creados: 0, temas_creados: 0, entradas_creadas: 0, errores: [] } end # Importa una bitácora específica desde archivo .md # # @param ruta_md [String] Ruta al archivo .md # @param opciones [Hash] Opciones de importación # :sobrescribir [Boolean] Sobrescribir bitácora si ya existe (default: false) # :crear_nodos [Boolean] Crear nodos automáticamente si no existen (default: true) # @return [Hash] Resultado de la importación con estadísticas def importar_bitacora(ruta_md, opciones = {}) defaults = { sobrescribir: false, crear_nodos: true } opciones = defaults.merge(opciones) @estadisticas[:archivos_procesados] += 1 begin # Leer y parsear archivo contenido = File.read(ruta_md, encoding: 'UTF-8') fecha = extraer_fecha_desde_ruta(ruta_md) || extraer_fecha_desde_contenido(contenido) if fecha.nil? error = "No se pudo determinar fecha del archivo: #{ruta_md}" @logger.error(error) @estadisticas[:errores] << error return { error: error } end @logger.info("Importando bitácora #{fecha} desde #{ruta_md}") # Verificar si ya existe bitácora bitacora_existente = @db.find_bitacora_by_fecha(fecha) if bitacora_existente && !opciones[:sobrescribir] @logger.advertencia("Bitácora #{fecha} ya existe, omitiendo (use --sobrescribir)") return { skip: true, mensaje: "Bitácora ya existe", bitacora_id: bitacora_existente['id'], estadisticas: @estadisticas } end # Parsear contenido estructura = parsear_contenido(contenido, fecha) # Importar a base de datos resultado = importar_estructura(estructura, opciones) resultado.merge( exitoso: resultado[:errores].empty?, fecha: fecha, estadisticas: @estadisticas.dup ) rescue => e error = "Error importando #{ruta_md}: #{e.message}" @logger.error(error) @estadisticas[:errores] << error { error: error, backtrace: e.backtrace, estadisticas: @estadisticas } end end # Importa todas las bitácoras de un directorio # # @param directorio [String] Ruta al directorio con archivos .md # @param opciones [Hash] Opciones de importación # @return [Hash] Resultado consolidado def importar_directorio(directorio, opciones = {}) archivos = Dir.glob(File.join(directorio, '*.md')).sort resultados = [] @logger.info("Importando #{archivos.length} archivos desde #{directorio}") archivos.each do |archivo| @logger.info("Procesando: #{File.basename(archivo)}") resultado = importar_bitacora(archivo, opciones) resultados << resultado.merge(archivo: archivo) end consolidar_resultados(resultados) end # Obtiene estadísticas de importación # # @return [Hash] Estadísticas acumuladas def estadisticas @estadisticas end private # Extrae fecha desde la ruta del archivo (ej: bitacoras/2026-02-28.md) def extraer_fecha_desde_ruta(ruta) nombre = File.basename(ruta, '.md') begin Date.parse(nombre) rescue Date::Error nil end end # Extrae fecha desde el contenido del archivo def extraer_fecha_desde_contenido(contenido) # Buscar patrón: # Bitácora 2026-02-28 match = contenido.match(/# Bitácora (\d{4}-\d{2}-\d{2})/) return Date.parse(match[1]) if match # Buscar patrón: # Bitácora de Operaciones - 28/02/2026 match = contenido.match(/# Bitácora de Operaciones - (\d{2}\/\d{2}\/\d{4})/) if match fecha_str = match[1] dia, mes, anio = fecha_str.split('/') return Date.parse("#{anio}-#{mes}-#{dia}") end nil end # Parsea el contenido del archivo .md a estructura jerárquica def parsear_contenido(contenido, fecha) lineas = contenido.lines.map(&:chomp) estructura = { fecha: fecha, resumen: extraer_resumen(contenido), nodos: {}, secciones: {} } nodo_actual = nil tema_actual = nil en_actividades_detalladas = false en_tabla_entradas = false encabezados_tabla = [] entradas_tabla = [] lineas.each_with_index do |linea, idx| # Detectar sección de actividades detalladas (con cualquier emoji o sin él) if linea.match?(/^## .*Actividades Detalladas/) en_actividades_detalladas = true next end # Detectar fin de sección (siguiente ##) if en_actividades_detalladas && linea.start_with?('## ') && !linea.match?(/.*Actividades Detalladas/) en_actividades_detalladas = false end # Solo procesar dentro de actividades detalladas next unless en_actividades_detalladas # Detectar nodo (### srv-ns8) if linea.start_with?('### ') && !linea.start_with?('####') nodo_nombre = linea.sub('### ', '').strip nodo_actual = nodo_nombre estructura[:nodos][nodo_actual] ||= { nombre: nodo_nombre, temas: {} } next end # Detectar tema (#### ✅ - SEC01 - Refactorización ns8-candados) if linea.start_with?('#### ') tema_linea = linea.sub('#### ', '').strip tema_info = parsear_tema(tema_linea) if tema_info && nodo_actual tema_actual = tema_info[:id_tema] estructura[:nodos][nodo_actual][:temas][tema_actual] = { id_tema: tema_info[:id_tema], titulo: tema_info[:titulo], estado: tema_info[:estado], descripcion: tema_info[:descripcion], entradas: [] } end next end # Detectar inicio de tabla de entradas if linea.match?(/^\| I \| F \| Descripción \| (?:J \| )?E \|$/) en_tabla_entradas = true encabezados_tabla = linea.split('|').map(&:strip).reject(&:empty?) entradas_tabla = [] next end # Detectar fin de tabla if en_tabla_entradas && (linea.empty? || linea.start_with?('#') || linea.start_with?('####')) # Procesar entradas acumuladas if tema_actual && nodo_actual && !entradas_tabla.empty? entradas_parseadas = parsear_entradas_tabla(entradas_tabla, encabezados_tabla) # Verificar que exista el tema antes de asignar entradas if estructura[:nodos][nodo_actual] && estructura[:nodos][nodo_actual][:temas][tema_actual] estructura[:nodos][nodo_actual][:temas][tema_actual][:entradas] = entradas_parseadas else @logger.advertencia("Tema #{tema_actual} no encontrado en nodo #{nodo_actual}, omitiendo entradas") end end en_tabla_entradas = false encabezados_tabla = [] entradas_tabla = [] end # Acumular líneas de tabla if en_tabla_entradas && linea.start_with?('|') entradas_tabla << linea end end # Procesar última tabla si estaba activa al final del archivo if en_tabla_entradas && tema_actual && nodo_actual && !entradas_tabla.empty? entradas_parseadas = parsear_entradas_tabla(entradas_tabla, encabezados_tabla) # Verificar que exista el tema antes de asignar entradas if estructura[:nodos][nodo_actual] && estructura[:nodos][nodo_actual][:temas][tema_actual] estructura[:nodos][nodo_actual][:temas][tema_actual][:entradas] = entradas_parseadas else @logger.advertencia("Tema #{tema_actual} no encontrado en nodo #{nodo_actual}, omitiendo entradas") end end estructura end # Extrae resumen general del contenido def extraer_resumen(contenido) # Buscar en sección "Resumen de Actividades" con cualquier emoji o sin él patron_resumen = /### .*Resumen de Actividades/ match_resumen = contenido.match(patron_resumen) if match_resumen seccion = contenido.split(match_resumen[0])[1] if seccion # Tomar las primeras líneas después de la tabla lineas = seccion.lines.map(&:chomp) resumen_lineas = [] en_tabla = false lineas.each do |linea| if linea.match?(/^\|.*\|$/) en_tabla = true elsif en_tabla && !linea.match?(/^\|.*\|$/) # Fin de tabla, empezar a capturar resumen if linea.strip.length > 10 # Ignorar líneas muy cortas resumen_lineas << linea.strip end end end return resumen_lineas.join(' ') if resumen_lineas.any? end end nil end # Parsea línea de tema (ej: "✅ - SEC01 - Refactorización ns8-candados") def parsear_tema(linea) # Intentar formato estándar: estado - id - título (ej: ✅ - ADN01 - Título) match_std = linea.match(/^([^\s-]+)\s*-\s*([A-Z0-9.]+)\s*-\s*(.+)$/) if match_std return { id_tema: match_std[2], titulo: match_std[3].strip, estado: ESTADOS_MD[match_std[1]] || '⏳', descripcion: match_std[3].strip } end # Intentar formato simplificado: estado - título (ej: 🎭 - Servicio Auditorio) match_simple = linea.match(/^([^\s-]+)\s*-\s*(.+)$/) if match_simple titulo = match_simple[2].strip # Generar un ID determinista basado en el título para estabilidad id_gen = "GEN" + Digest::MD5.hexdigest(titulo)[0..5].upcase return { id_tema: id_gen, titulo: titulo, estado: ESTADOS_MD[match_simple[1]] || '⏳', descripcion: titulo } end nil end # Parsea líneas de tabla de entradas def parsear_entradas_tabla(lineas_tabla, encabezados) entradas = [] # Mapeo de columnas basado en encabezados columnas = {} encabezados.each_with_index do |encabezado, idx| case encabezado.downcase when 'i' then columnas[:inicio] = idx when 'f' then columnas[:fin] = idx when 'descripción' then columnas[:descripcion] = idx when 'j' then columnas[:jornada] = idx when 'e' then columnas[:estado] = idx end end lineas_tabla.each do |linea| # Saltar separadores de tabla Markdown (ej: | :--- | :--- | :--- | :--- |) next if linea.match?(/^[\|\s:-]+$/) # Solo pipes, espacios, guiones y dos puntos next if linea.match?(/^\|?\s*:?-+:?\s*\|/) # Separadores con :---, ---, :-:, etc. # Limpiar pipes iniciales y finales antes de split linea_limpia = linea.strip.gsub(/^\||\|$/, '') celdas = linea_limpia.split('|').map(&:strip) next if celdas.length < 3 # Mínimo: inicio, descripción, estado # Validar que la celda de inicio tenga contenido valor_inicio = celdas[columnas[:inicio]] next if valor_inicio.nil? || valor_inicio.empty? || valor_inicio.match?(/^:?-+:?$/) fin_valor = nil if columnas[:fin] && !celdas[columnas[:fin]].nil? raw_fin = celdas[columnas[:fin]].strip fin_valor = raw_fin if !raw_fin.empty? && raw_fin != '-' end entrada = { inicio: valor_inicio.gsub(/\s*\(.*?\)\s*/, '').strip, fin: fin_valor ? fin_valor.gsub(/\s*\(.*?\)\s*/, '').strip : nil, descripcion: celdas[columnas[:descripcion]], estado_modo: celdas[columnas[:estado]] } # Parsear estado y modo if columnas[:jornada] && columnas[:estado] # Nuevo formato: J y E separados # Unimos temporalmente para usar parsear_estado_modo existente campo_combinado = "[#{celdas[columnas[:jornada]]}] #{celdas[columnas[:estado]]}" estado_modo = parsear_estado_modo(campo_combinado) entrada[:estado] = estado_modo[:estado] entrada[:modo] = estado_modo[:modo] elsif columnas[:estado] # Formato viejo o solo E estado_modo = parsear_estado_modo(celdas[columnas[:estado]]) entrada[:estado] = estado_modo[:estado] entrada[:modo] = estado_modo[:modo] end entradas << entrada end entradas end # Parsea campo estado/modo (ej: "[P] ✅") def parsear_estado_modo(campo) estado = '⏳' modo = 'P' # Buscar estado en el campo ESTADOS_MD.each do |md_estado, bd_estado| if campo.include?(md_estado) estado = bd_estado break end end # Buscar modo en el campo MODOS_MD.each do |md_modo, bd_modo| if campo.include?(md_modo) modo = bd_modo break end end { estado: estado, modo: modo } end # Importa estructura parseada a base de datos def importar_estructura(estructura, opciones) errores = [] begin # 1. Crear o actualizar bitácora bitacora = @db.find_bitacora_by_fecha(estructura[:fecha]) if bitacora @logger.info("Bitácora #{estructura[:fecha]} ya existe (ID: #{bitacora['id']})") else bitacora = @db.create_bitacora( fecha: estructura[:fecha], resumen: estructura[:resumen] ) @estadisticas[:bitacoras_creadas] += 1 @logger.info("Bitácora creada: #{estructura[:fecha]} (ID: #{bitacora['id']})") end bitacora_id = bitacora['id'] # 1b. Si se solicita sobrescribir, limpiar entradas previas de esta bitácora if opciones[:sobrescribir] @logger.info("Limpiando datos previos de la bitácora #{estructura[:fecha]}...") # Borrar entradas primero (FK) @db.execute("DELETE FROM bitacoras.entradas WHERE bitacora_id = $1", [bitacora_id]) # Borrar temas (FK) @db.execute("DELETE FROM bitacoras.temas WHERE bitacora_id = $1", [bitacora_id]) @estadisticas[:datos_limpiados] = true end # 2. Procesar cada nodo estructura[:nodos].each do |nombre_nodo, datos_nodo| # Buscar o crear nodo nodo = @db.find_nodo_by_nombre(nombre_nodo) if nodo.nil? && opciones[:crear_nodos] tipo = inferir_tipo_nodo(nombre_nodo) nodo = @db.create_nodo( nombre: nombre_nodo, tipo: tipo, descripcion: "Importado desde bitácora #{estructura[:fecha]}" ) @estadisticas[:nodos_creados] += 1 @logger.info("Nodo creado: #{nombre_nodo} (ID: #{nodo['id']})") elsif nodo.nil? @logger.advertencia("Nodo #{nombre_nodo} no existe y crear_nodos=false, omitiendo") next end nodo_id = nodo['id'] # 3. Procesar temas del nodo datos_nodo[:temas].each do |id_tema, datos_tema| # Buscar tema por id_tema y bitácora temas = @db.list_temas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100) tema_existente = temas.find { |t| t['id_tema'] == id_tema } if tema_existente @logger.info("Tema #{id_tema} ya existe (ID: #{tema_existente['id']})") tema_id = tema_existente['id'] else tema = @db.create_tema( id_tema: id_tema, titulo: datos_tema[:titulo], estado: datos_tema[:estado], descripcion: datos_tema[:descripcion], bitacora_id: bitacora_id, nodo_id: nodo_id ) tema_id = tema['id'] @estadisticas[:temas_creados] += 1 @logger.info("Tema creado: #{id_tema} (ID: #{tema_id})") end # 4. Procesar entradas del tema datos_tema[:entradas].each do |entrada_md| # Validar campos requeridos next unless entrada_md[:inicio] && entrada_md[:descripcion] # Intentar buscar entrada existente para evitar duplicados (Upsert) # Buscamos por: bitacora_id, nodo_id y hora de inicio (única para un nodo en un día) entradas_nodo = @db.list_entradas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100) # Normalizar hora para comparación inicio_norm = @db.send(:parse_time, entrada_md[:inicio]) existente = entradas_nodo.find { |e| e['inicio'] == inicio_norm } begin if existente # Actualizar si ya existe @db.update_entrada(existente['id'], { fin: entrada_md[:fin], descripcion: entrada_md[:descripcion], estado: entrada_md[:estado] || '⏳', modo: entrada_md[:modo] || 'P' }) @estadisticas[:entradas_actualizadas] ||= 0 @estadisticas[:entradas_actualizadas] += 1 else # Crear si no existe @db.create_entrada( inicio: entrada_md[:inicio], fin: entrada_md[:fin], descripcion: entrada_md[:descripcion], estado: entrada_md[:estado] || '⏳', modo: entrada_md[:modo] || 'P', es_ia: false, tema_id: tema_id, bitacora_id: bitacora_id, nodo_id: nodo_id ) @estadisticas[:entradas_creadas] += 1 end rescue => e @logger.error("Error procesando entrada '#{entrada_md[:inicio]} - #{entrada_md[:descripcion]}': #{e.message}") end end end end rescue => e error = "Error importando estructura: #{e.message}" @logger.error(error) errores << error end { errores: errores } end # Infiere tipo de nodo basado en su nombre def inferir_tipo_nodo(nombre) TIPOS_NODO.each do |patron, tipo| return tipo if nombre.match?(patron) end 'servidor' # Tipo por defecto end # Consolida resultados de múltiples importaciones def consolidar_resultados(resultados) totales = { archivos: resultados.length, exitosos: resultados.count { |r| r[:exitoso] == true }, omitidos: resultados.count { |r| r[:skip] == true }, errores: resultados.count { |r| r[:error] } } estadisticas_consolidadas = { archivos_procesados: resultados.length, bitacoras_creadas: resultados.sum { |r| r.dig(:estadisticas, :bitacoras_creadas) || 0 }, nodos_creados: resultados.sum { |r| r.dig(:estadisticas, :nodos_creados) || 0 }, temas_creados: resultados.sum { |r| r.dig(:estadisticas, :temas_creados) || 0 }, entradas_creadas: resultados.sum { |r| r.dig(:estadisticas, :entradas_creadas) || 0 }, errores: resultados.flat_map { |r| r.dig(:estadisticas, :errores) || [] }.compact } { totales: totales, estadisticas: estadisticas_consolidadas, detalles: resultados } end # Logger por defecto def default_logger Class.new do def info(msg); puts "[INFO] #{msg}"; end def advertencia(msg); puts "[ADVERTENCIA] #{msg}"; end def error(msg); puts "[ERROR] #{msg}"; end end.new end end end end